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: workflow_dispatch:
env: env:
GO_VERSION: '1.25.5' GO_VERSION: '1.26'
NODE_VERSION: '24' NODE_VERSION: '24'
jobs: 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) [![GitHub All Releases](https://img.shields.io/github/downloads/afkarxyz/SpotiFLAC/total?style=for-the-badge)](https://github.com/afkarxyz/SpotiFLAC/releases)
[![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac)
<!-- ![Maintenance](https://maintenance.afkarxyz.fun?v=3) --> [![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) ![Image](https://github.com/user-attachments/assets/a6e92fdd-2944-45c1-83e8-e23a26c827af)
<div align="center"> <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=) ![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) ![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 ## 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 ## Other projects
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader) ### [SpotiFLAC Next](https://github.com/spotiverse/SpotiFLAC-Next)
Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API.
Get Spotify tracks in Hi-Res lossless FLACs — no account required.
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
Get Spotify tracks in MP3 and FLAC via spotidownloader.com
### [SpotubeDL](https://spotubedl.com) ### [SpotubeDL](https://spotubedl.com)
Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus with High Quality. Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus with High Quality.
### [SpotiFLAC (Mobile)](https://github.com/zarzet/SpotiFLAC-Mobile) ### [SpotiFLAC (Mobile)](https://github.com/zarzet/SpotiFLAC-Mobile)
SpotiFLAC for Android & iOS — maintained by [@zarzet](https://github.com/zarzet) SpotiFLAC for Android & iOS — maintained by [@zarzet](https://github.com/zarzet)
## FAQ (Frequently Asked Questions) ## FAQ
### Is this software free? ### 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. **SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
You are solely responsible for: You are solely responsible for:
1. Ensuring your use of this software complies with your local laws. 1. Ensuring your use of this software complies with your local laws.
2. Reading and adhering to the Terms of Service of the respective platforms. 2. Reading and adhering to the Terms of Service of the respective platforms.
3. Any legal consequences resulting from the misuse of this tool. 3. Any legal consequences resulting from the misuse of this tool.
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use. The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
## API Credits
[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] > [!TIP]
> >
> **Star Us**, You will receive all release notifications from GitHub without any delay ~ > **Star Us**, You will receive all release notifications from GitHub without any delay ~
+364 -125
View File
@@ -8,21 +8,15 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"spotiflac/backend"
"strings" "strings"
"time" "time"
"github.com/afkarxyz/SpotiFLAC/backend"
"github.com/wailsapp/wails/v2/pkg/runtime" "github.com/wailsapp/wails/v2/pkg/runtime"
) )
var isrcRegex = regexp.MustCompile(`^[A-Z]{2}[A-Z0-9]{3}\d{2}\d{5}$`)
func isValidISRC(isrc string) bool {
return isrcRegex.MatchString(isrc)
}
type App struct { type App struct {
ctx context.Context ctx context.Context
} }
@@ -31,6 +25,19 @@ func NewApp() *App {
return &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) { func (a *App) startup(ctx context.Context) {
a.ctx = ctx a.ctx = ctx
@@ -51,7 +58,6 @@ type SpotifyMetadataRequest struct {
} }
type DownloadRequest struct { type DownloadRequest struct {
ISRC string `json:"isrc"`
Service string `json:"service"` Service string `json:"service"`
Query string `json:"query,omitempty"` Query string `json:"query,omitempty"`
TrackName string `json:"track_name,omitempty"` TrackName string `json:"track_name,omitempty"`
@@ -79,6 +85,12 @@ type DownloadRequest struct {
SpotifyTotalDiscs int `json:"spotify_total_discs,omitempty"` SpotifyTotalDiscs int `json:"spotify_total_discs,omitempty"`
Copyright string `json:"copyright,omitempty"` Copyright string `json:"copyright,omitempty"`
Publisher string `json:"publisher,omitempty"` Publisher string `json:"publisher,omitempty"`
PlaylistName string `json:"playlist_name,omitempty"`
PlaylistOwner string `json:"playlist_owner,omitempty"`
AllowFallback bool `json:"allow_fallback"`
UseFirstArtistOnly bool `json:"use_first_artist_only,omitempty"`
UseSingleGenre bool `json:"use_single_genre,omitempty"`
EmbedGenre bool `json:"embed_genre,omitempty"`
} }
type DownloadResponse struct { type DownloadResponse struct {
@@ -90,14 +102,14 @@ type DownloadResponse struct {
ItemID string `json:"item_id,omitempty"` ItemID string `json:"item_id,omitempty"`
} }
func (a *App) GetStreamingURLs(spotifyTrackID string) (string, error) { func (a *App) GetStreamingURLs(spotifyTrackID string, region string) (string, error) {
if spotifyTrackID == "" { if spotifyTrackID == "" {
return "", fmt.Errorf("spotify track ID is required") return "", fmt.Errorf("spotify track ID is required")
} }
fmt.Printf("[GetStreamingURLs] Called for track ID: %s\n", spotifyTrackID) fmt.Printf("[GetStreamingURLs] Called for track ID: %s, Region: %s\n", spotifyTrackID, region)
client := backend.NewSongLinkClient() client := backend.NewSongLinkClient()
urls, err := client.GetAllURLsFromSpotify(spotifyTrackID) urls, err := client.GetAllURLsFromSpotify(spotifyTrackID, region)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -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))) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.Timeout*float64(time.Second)))
defer cancel() 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))) data, err := backend.GetFilteredSpotifyData(ctx, req.URL, req.Batch, time.Duration(req.Delay*float64(time.Second)))
if err != nil { if err != nil {
return "", fmt.Errorf("failed to fetch metadata: %v", err) 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) { func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
if req.Service == "qobuz" && req.ISRC == "" && req.SpotifyID == "" { if req.Service == "qobuz" && req.SpotifyID == "" {
return DownloadResponse{ return DownloadResponse{
Success: false, Success: false,
Error: "Spotify ID is required for Qobuz", Error: "Spotify ID is required for Qobuz",
@@ -201,7 +234,12 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
req.OutputDir = "." req.OutputDir = "."
} else { } else {
req.OutputDir = backend.NormalizePath(req.OutputDir) if req.PlaylistName != "" {
sanitizedPlaylist := backend.SanitizeFilename(req.PlaylistName)
req.OutputDir = filepath.Join(req.OutputDir, sanitizedPlaylist)
}
req.OutputDir = backend.SanitizeFolderPath(req.OutputDir)
} }
if req.AudioFormat == "" { if req.AudioFormat == "" {
@@ -281,7 +319,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
} }
if req.TrackName != "" && req.ArtistName != "" { if req.TrackName != "" && req.ArtistName != "" {
expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.FilenameFormat, req.TrackNumber, req.Position, req.SpotifyDiscNumber, req.UseAlbumTrackNumber) expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.SpotifyDiscNumber, req.UseAlbumTrackNumber)
expectedPath := filepath.Join(req.OutputDir, expectedFilename) expectedPath := filepath.Join(req.OutputDir, expectedFilename)
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 { if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
@@ -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 { switch req.Service {
case "amazon": case "amazon":
downloader := backend.NewAmazonDownloader() downloader := backend.NewAmazonDownloader()
if req.ServiceURL != "" { if req.ServiceURL != "" {
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL)
} else { } else {
if req.SpotifyID == "" { 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)
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)
} }
case "tidal": case "tidal":
if req.ApiURL == "" || req.ApiURL == "auto" { if req.ApiURL == "" || req.ApiURL == "auto" {
downloader := backend.NewTidalDownloader("") downloader := backend.NewTidalDownloader("")
if req.ServiceURL != "" { if req.ServiceURL != "" {
filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL)
} else { } else {
if req.SpotifyID == "" { 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)
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)
} }
} else { } else {
downloader := backend.NewTidalDownloader(req.ApiURL) downloader := backend.NewTidalDownloader(req.ApiURL)
if req.ServiceURL != "" { if req.ServiceURL != "" {
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL)
} else { } else {
if req.SpotifyID == "" { 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)
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)
} }
} }
case "qobuz": case "qobuz":
downloader := backend.NewQobuzDownloader()
fmt.Println("Waiting for ISRC (Qobuz dependency)...")
isrc := <-isrcChan
downloader := backend.NewQobuzDownloader()
quality := req.AudioFormat quality := req.AudioFormat
if quality == "" { if quality == "" {
quality = "6" 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 case "deezer":
downloader := backend.NewDeezerDownloader()
if len(deezerISRC) != 12 || !isValidISRC(deezerISRC) { 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)
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)
default: default:
return DownloadResponse{ return DownloadResponse{
@@ -419,53 +439,30 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
filename = strings.TrimPrefix(filename, "EXISTS:") filename = strings.TrimPrefix(filename, "EXISTS:")
} }
if !alreadyExists && req.SpotifyID != "" && req.EmbedLyrics && strings.HasSuffix(filename, ".flac") { if !alreadyExists && req.SpotifyID != "" && req.EmbedLyrics && (strings.HasSuffix(filename, ".flac") || strings.HasSuffix(filename, ".mp3") || strings.HasSuffix(filename, ".m4a")) {
go func(filePath, spotifyID, trackName, artistName string) { fmt.Printf("\nWaiting for lyrics fetch to complete...\n")
fmt.Printf("\n========== LYRICS FETCH START ==========\n") lyrics := <-lyricsChan
fmt.Printf("Spotify ID: %s\n", spotifyID) if lyrics != "" {
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
}
fmt.Printf("\n--- Full LRC Content ---\n") fmt.Printf("\n--- Full LRC Content ---\n")
fmt.Println(lyrics) fmt.Println(lyrics)
fmt.Printf("--- End LRC Content ---\n\n") fmt.Printf("--- End LRC Content ---\n\n")
fmt.Printf("Embedding into: %s\n", filePath) fmt.Printf("Embedding into: %s\n", filename)
if err := backend.EmbedLyricsOnly(filePath, lyrics); err != nil {
if err := backend.EmbedLyricsOnlyUniversal(filename, lyrics); err != nil {
fmt.Printf("Failed to embed lyrics: %v\n", err) fmt.Printf("Failed to embed lyrics: %v\n", err)
fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n")
} else { } else {
fmt.Printf("Lyrics embedded successfully!\n") 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" message := "Download completed successfully"
@@ -488,11 +485,17 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
meta, err := backend.GetTrackMetadata(fPath) meta, err := backend.GetTrackMetadata(fPath)
if err == nil && meta != nil { 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) d := int(meta.Duration)
durationStr = fmt.Sprintf("%d:%02d", d/60, d%60) durationStr = fmt.Sprintf("%d:%02d", d/60, d%60)
} else { } else {
fmt.Printf("[History] Failed to get metadata for %s: %v\n", fPath, err)
} }
item := backend.HistoryItem{ item := backend.HistoryItem{
@@ -503,7 +506,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
DurationStr: durationStr, DurationStr: durationStr,
CoverURL: cover, CoverURL: cover,
Quality: quality, Quality: quality,
Format: format, Format: strings.ToUpper(format),
Path: fPath, Path: fPath,
} }
@@ -513,6 +516,12 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
item.Format = strings.ToUpper(ext[1:]) item.Format = strings.ToUpper(ext[1:])
} }
} }
switch item.Format {
case "6", "7", "27":
item.Format = "FLAC"
}
backend.AddHistoryItem(item, "SpotiFLAC") backend.AddHistoryItem(item, "SpotiFLAC")
}(filename, req.TrackName, req.ArtistName, req.AlbumName, req.SpotifyID, req.CoverURL, req.AudioFormat) }(filename, req.TrackName, req.ArtistName, req.AlbumName, req.SpotifyID, req.CoverURL, req.AudioFormat)
} }
@@ -569,9 +578,9 @@ func (a *App) ClearAllDownloads() {
backend.ClearAllDownloads() backend.ClearAllDownloads()
} }
func (a *App) AddToDownloadQueue(isrc, trackName, artistName, albumName string) string { func (a *App) AddToDownloadQueue(spotifyID, trackName, artistName, albumName string) string {
itemID := fmt.Sprintf("%s-%d", isrc, time.Now().UnixNano()) itemID := fmt.Sprintf("%s-%d", spotifyID, time.Now().UnixNano())
backend.AddToQueue(itemID, trackName, artistName, albumName, isrc) backend.AddToQueue(itemID, trackName, artistName, albumName, "")
return itemID return itemID
} }
@@ -583,6 +592,74 @@ func (a *App) CancelAllQueuedItems() {
backend.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() { func (a *App) Quit() {
panic("quit") panic("quit")
@@ -596,6 +673,30 @@ func (a *App) ClearDownloadHistory() error {
return backend.ClearHistory("SpotiFLAC") return backend.ClearHistory("SpotiFLAC")
} }
func (a *App) DeleteDownloadHistoryItem(id string) error {
return backend.DeleteHistoryItem(id, "SpotiFLAC")
}
func (a *App) GetFetchHistory() ([]backend.FetchHistoryItem, error) {
return backend.GetFetchHistoryItems("SpotiFLAC")
}
func (a *App) AddFetchHistory(item backend.FetchHistoryItem) error {
return backend.AddFetchHistoryItem(item, "SpotiFLAC")
}
func (a *App) ClearFetchHistory() error {
return backend.ClearFetchHistory("SpotiFLAC")
}
func (a *App) DeleteFetchHistoryItem(id string) error {
return backend.DeleteFetchHistoryItem(id, "SpotiFLAC")
}
func (a *App) ClearFetchHistoryByType(itemType string) error {
return backend.ClearFetchHistoryByType(itemType, "SpotiFLAC")
}
func (a *App) AnalyzeTrack(filePath string) (string, error) { func (a *App) AnalyzeTrack(filePath string) (string, error) {
if filePath == "" { if filePath == "" {
return "", fmt.Errorf("file path is required") return "", fmt.Errorf("file path is required")
@@ -855,13 +956,13 @@ func (a *App) DownloadAvatar(req AvatarDownloadRequest) (backend.AvatarDownloadR
return *resp, nil return *resp, nil
} }
func (a *App) CheckTrackAvailability(spotifyTrackID string, isrc string) (string, error) { func (a *App) CheckTrackAvailability(spotifyTrackID string) (string, error) {
if spotifyTrackID == "" { if spotifyTrackID == "" {
return "", fmt.Errorf("spotify track ID is required") return "", fmt.Errorf("spotify track ID is required")
} }
client := backend.NewSongLinkClient() client := backend.NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyTrackID, isrc) availability, err := client.CheckTrackAvailability(spotifyTrackID)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -987,6 +1088,27 @@ func (a *App) RenameFileTo(oldPath, newName string) error {
return os.Rename(oldPath, newPath) return os.Rename(oldPath, newPath)
} }
func (a *App) UploadImage(filePath string) (string, error) {
return backend.UploadToSendNow(filePath)
}
func (a *App) UploadImageBytes(filename string, base64Data string) (string, error) {
if idx := strings.Index(base64Data, ","); idx != -1 {
base64Data = base64Data[idx+1:]
}
data, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
return "", fmt.Errorf("failed to decode base64: %v", err)
}
return backend.UploadBytesToSendNow(filename, data)
}
func (a *App) SelectImageVideo() ([]string, error) {
return backend.SelectImageVideoDialog(a.ctx)
}
func (a *App) ReadImageAsBase64(filePath string) (string, error) { func (a *App) ReadImageAsBase64(filePath string) (string, error) {
content, err := os.ReadFile(filePath) content, err := os.ReadFile(filePath)
if err != nil { if err != nil {
@@ -1026,6 +1148,7 @@ type CheckFileExistenceRequest struct {
FilenameFormat string `json:"filename_format,omitempty"` FilenameFormat string `json:"filename_format,omitempty"`
IncludeTrackNumber bool `json:"include_track_number,omitempty"` IncludeTrackNumber bool `json:"include_track_number,omitempty"`
AudioFormat string `json:"audio_format,omitempty"` AudioFormat string `json:"audio_format,omitempty"`
RelativePath string `json:"relative_path,omitempty"`
} }
type CheckFileExistenceResult struct { type CheckFileExistenceResult struct {
@@ -1036,12 +1159,15 @@ type CheckFileExistenceResult struct {
ArtistName string `json:"artist_name,omitempty"` 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 { if len(tracks) == 0 {
return []CheckFileExistenceResult{} return []CheckFileExistenceResult{}
} }
outputDir = backend.NormalizePath(outputDir) outputDir = backend.NormalizePath(outputDir)
if rootDir != "" {
rootDir = backend.NormalizePath(rootDir)
}
defaultFilenameFormat := "title-artist" defaultFilenameFormat := "title-artist"
@@ -1052,6 +1178,30 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR
resultsChan := make(chan result, len(tracks)) 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 { for i, track := range tracks {
go func(idx int, t CheckFileExistenceRequest) { go func(idx int, t CheckFileExistenceRequest) {
res := CheckFileExistenceResult{ res := CheckFileExistenceResult{
@@ -1088,6 +1238,8 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR
t.AlbumArtist, t.AlbumArtist,
t.ReleaseDate, t.ReleaseDate,
filenameFormat, filenameFormat,
"",
"",
t.IncludeTrackNumber, t.IncludeTrackNumber,
trackNumber, trackNumber,
t.DiscNumber, t.DiscNumber,
@@ -1096,11 +1248,19 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR
expectedFilename := strings.TrimSuffix(expectedFilenameBase, ".flac") + fileExt expectedFilename := strings.TrimSuffix(expectedFilenameBase, ".flac") + fileExt
expectedPath := filepath.Join(outputDir, expectedFilename) targetDir := outputDir
if t.RelativePath != "" {
targetDir = filepath.Join(outputDir, t.RelativePath)
}
expectedPath := filepath.Join(targetDir, expectedFilename)
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 { if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
res.Exists = true res.Exists = true
res.FilePath = expectedPath res.FilePath = expectedPath
} else {
res.FilePath = expectedFilename
} }
resultsChan <- result{index: idx, result: res} resultsChan <- result{index: idx, result: res}
@@ -1108,9 +1268,39 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR
} }
results := make([]CheckFileExistenceResult, len(tracks)) results := make([]CheckFileExistenceResult, len(tracks))
missingIndices := []int{}
for i := 0; i < len(tracks); i++ { for i := 0; i < len(tracks); i++ {
r := <-resultsChan r := <-resultsChan
results[r.index] = r.result 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 return results
@@ -1183,3 +1373,52 @@ func (a *App) CheckFFmpegInstalled() (bool, error) {
func (a *App) GetOSInfo() (string, error) { func (a *App) GetOSInfo() (string, error) {
return backend.GetOSInfo() 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 package backend
import ( import (
"bytes"
"crypto/tls"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"math/rand"
"net/http" "net/http"
"net/http/cookiejar"
"net/url" "net/url"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
@@ -19,11 +15,8 @@ import (
) )
type AmazonDownloader struct { type AmazonDownloader struct {
client *http.Client client *http.Client
regions []string regions []string
lastAPICallTime time.Time
apiCallCount int
apiCallResetTime time.Time
} }
type SongLinkResponse struct { type SongLinkResponse struct {
@@ -32,35 +25,9 @@ type SongLinkResponse struct {
} `json:"linksByPlatform"` } `json:"linksByPlatform"`
} }
type DoubleDoubleSubmitResponse struct { type AmazonStreamResponse struct {
Success bool `json:"success"` StreamURL string `json:"streamUrl"`
ID string `json:"id"` DecryptionKey string `json:"decryptionKey"`
}
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"`
} }
func NewAmazonDownloader() *AmazonDownloader { func NewAmazonDownloader() *AmazonDownloader {
@@ -68,93 +35,36 @@ func NewAmazonDownloader() *AmazonDownloader {
client: &http.Client{ client: &http.Client{
Timeout: 120 * time.Second, Timeout: 120 * time.Second,
}, },
regions: []string{"us", "eu"}, regions: []string{"us", "eu"},
apiCallResetTime: time.Now(),
} }
} }
func (a *AmazonDownloader) getRandomUserAgent() string {
return fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
rand.Intn(4)+11, rand.Intn(5)+4,
rand.Intn(7)+530, rand.Intn(7)+30,
rand.Intn(25)+80, rand.Intn(1500)+3000, rand.Intn(65)+60,
rand.Intn(7)+530, rand.Intn(6)+30)
}
func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) { func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) {
now := time.Now() spotifyBase := "https://open.spotify.com/track/"
if now.Sub(a.apiCallResetTime) >= time.Minute { spotifyURL := fmt.Sprintf("%s%s", spotifyBase, spotifyTrackID)
a.apiCallCount = 0
a.apiCallResetTime = now
}
if a.apiCallCount >= 9 { apiBase := "https://api.song.link/v1-alpha.1/links?url="
waitTime := time.Minute - now.Sub(a.apiCallResetTime) apiURL := fmt.Sprintf("%s%s", apiBase, url.QueryEscape(spotifyURL))
if waitTime > 0 {
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
a.apiCallCount = 0
a.apiCallResetTime = time.Now()
}
}
if !a.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(a.lastAPICallTime)
minDelay := 7 * time.Second
if timeSinceLastCall < minDelay {
waitTime := minDelay - timeSinceLastCall
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
}
}
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
req, err := http.NewRequest("GET", apiURL, nil) req, err := http.NewRequest("GET", apiURL, nil)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to create request: %w", err) return "", fmt.Errorf("failed to create request: %w", err)
} }
req.Header.Set("User-Agent", "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("User-Agent", a.getRandomUserAgent())
fmt.Println("Getting Amazon URL...") fmt.Println("Getting Amazon URL...")
maxRetries := 3 resp, err := a.client.Do(req)
var resp *http.Response if err != nil {
for i := 0; i < maxRetries; i++ { return "", fmt.Errorf("failed to get Amazon URL: %w", err)
resp, err = a.client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to get Amazon URL: %w", err)
}
a.lastAPICallTime = time.Now()
a.apiCallCount++
if resp.StatusCode == 429 {
resp.Body.Close()
if i < maxRetries-1 {
waitTime := 15 * time.Second
fmt.Printf("Rate limited by API, waiting %v before retry...\n", waitTime)
time.Sleep(waitTime)
continue
}
return "", fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
}
if resp.StatusCode != 200 {
resp.Body.Close()
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
}
break
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err) return "", fmt.Errorf("failed to read response body: %w", err)
@@ -185,8 +95,7 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
parts := strings.Split(amazonURL, "trackAsin=") parts := strings.Split(amazonURL, "trackAsin=")
if len(parts) > 1 { if len(parts) > 1 {
trackAsin := strings.Split(parts[1], "&")[0] trackAsin := strings.Split(parts[1], "&")[0]
musicBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9tdXNpYy5hbWF6b24uY29tL3RyYWNrcy8=") amazonURL = fmt.Sprintf("https://music.amazon.com/tracks/%s?musicTerritory=US", trackAsin)
amazonURL = fmt.Sprintf("%s%s?musicTerritory=US", string(musicBase), trackAsin)
} }
} }
@@ -194,374 +103,163 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
return amazonURL, nil return amazonURL, nil
} }
func (a *AmazonDownloader) extractData(html string, patterns []string) string { func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality string) (string, error) {
for _, p := range patterns {
re := regexp.MustCompile(p)
matches := re.FindStringSubmatch(html)
if len(matches) > 1 {
return matches[1]
}
}
return ""
}
func (a *AmazonDownloader) DownloadFromLucida(amazonURL, outputDir, quality string) (string, error) { asinRegex := regexp.MustCompile(`(B[0-9A-Z]{9})`)
tr := &http.Transport{ asin := asinRegex.FindString(amazonURL)
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, if asin == "" {
} return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL)
jar, _ := cookiejar.New(nil)
client := &http.Client{
Transport: tr,
Jar: jar,
Timeout: 120 * time.Second,
} }
userAgent := a.getRandomUserAgent() apiURL := fmt.Sprintf("https://amzn.afkarxyz.fun/api/track/%s", asin)
req, err := http.NewRequest("GET", apiURL, nil)
fmt.Printf("Initializing lucida for Amazon Music... (Target: %s)\n", amazonURL)
lucidaBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9sdWNpZGEudG8vP3VybD0lcyZjb3VudHJ5PWF1dG8=")
lucidaURL := fmt.Sprintf(string(lucidaBase), url.QueryEscape(amazonURL))
req, _ := http.NewRequest("GET", lucidaURL, nil)
req.Header.Set("User-Agent", userAgent)
resp, err := client.Do(req)
if err != nil { if err != nil {
return "", err return "", err
} }
defer resp.Body.Close() 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) fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin)
html := string(bodyBytes) resp, err := a.client.Do(req)
token := a.extractData(html, []string{`token:"([^"]+)"`, `"token"\s*:\s*"([^"]+)"`})
streamURL := a.extractData(html, []string{`"url":"([^"]+)"`, `url:"([^"]+)"`})
expiry := a.extractData(html, []string{`tokenExpiry:(\d+)`, `"tokenExpiry"\s*:\s*(\d+)`})
if token == "" || streamURL == "" {
errorMsg := a.extractData(html, []string{`error:"([^"]+)"`, `"error"\s*:\s*"([^"]+)"`})
if errorMsg != "" {
return "", fmt.Errorf("lucida error: %s", errorMsg)
}
return "", fmt.Errorf("could not extract required data from lucida")
}
decodedToken := token
if secondBase64, err := base64.StdEncoding.DecodeString(token); err == nil {
if firstBase64, err := base64.StdEncoding.DecodeString(string(secondBase64)); err == nil {
decodedToken = string(firstBase64)
}
}
streamURL = strings.ReplaceAll(streamURL, `\/`, `/`)
fmt.Printf("Fetching Amazon stream via Lucida...\n")
loadPayload := map[string]interface{}{
"account": map[string]string{"id": "auto", "type": "country"},
"compat": "false", "downscale": "original", "handoff": true,
"metadata": true, "private": true,
"token": map[string]interface{}{"primary": decodedToken, "expiry": expiry},
"upload": map[string]bool{"enabled": false},
"url": streamURL,
}
payloadBytes, _ := json.Marshal(loadPayload)
loadAPI, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9sdWNpZGEudG8vYXBpL2xvYWQ/dXJsPS9hcGkvZmV0Y2gvc3RyZWFtL3Yy")
req, _ = http.NewRequest("POST", string(loadAPI), bytes.NewBuffer(payloadBytes))
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Content-Type", "application/json")
for _, cookie := range client.Jar.Cookies(req.URL) {
if cookie.Name == "csrf_token" {
req.Header.Set("X-CSRF-Token", cookie.Value)
}
}
resp, err = client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var loadData LucidaLoadResponse
json.NewDecoder(resp.Body).Decode(&loadData)
if !loadData.Success {
return "", fmt.Errorf("lucida load request failed: %s", loadData.Error)
}
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=")
completionBase, _ := base64.StdEncoding.DecodeString("Lmx1Y2lkYS50by9hcGkvZmV0Y2gvcmVxdWVzdC8=")
completionURL := fmt.Sprintf("%s%s%s%s", string(serviceBase), loadData.Server, string(completionBase), loadData.Handoff)
fmt.Println("Processing on Lucida server...")
var finalStatus LucidaStatusResponse
for {
req, _ = http.NewRequest("GET", completionURL, nil)
req.Header.Set("User-Agent", userAgent)
resp, err = client.Do(req)
if err != nil {
return "", err
}
json.NewDecoder(resp.Body).Decode(&finalStatus)
resp.Body.Close()
if finalStatus.Status == "completed" {
fmt.Println("\nTrack processing completed!")
break
} else if finalStatus.Status == "error" {
return "", fmt.Errorf("lucida processing failed: %s", finalStatus.Message)
} else if finalStatus.Progress.Total > 0 {
percent := (finalStatus.Progress.Current * 100) / finalStatus.Progress.Total
fmt.Printf("\rLucida Progress: %d%%", percent)
}
time.Sleep(2 * time.Second)
}
downloadSuffix, _ := base64.StdEncoding.DecodeString("L2Rvd25sb2Fk")
downloadURL := fmt.Sprintf("%s%s%s%s%s", string(serviceBase), loadData.Server, string(completionBase), loadData.Handoff, string(downloadSuffix))
req, _ = http.NewRequest("GET", downloadURL, nil)
req.Header.Set("User-Agent", userAgent)
resp, err = client.Do(req)
if err != nil { if err != nil {
return "", err return "", err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return "", fmt.Errorf("lucida download failed with status %d", resp.StatusCode) return "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
} }
fileName := "track.flac" bodyBytes, err := io.ReadAll(resp.Body)
contentDisp := resp.Header.Get("Content-Disposition") if err != nil {
if contentDisp != "" { return "", err
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, "")
}
} }
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) filePath := filepath.Join(outputDir, fileName)
out, err := os.Create(filePath) out, err := os.Create(filePath)
if err != nil { if err != nil {
return "", err return "", err
} }
defer out.Close() defer out.Close()
fmt.Printf("Downloading from Lucida: %s\n", fileName) dlReq, _ := http.NewRequest("GET", downloadURL, nil)
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) pw := NewProgressWriter(out)
_, err = io.Copy(pw, resp.Body) _, err = io.Copy(pw, dlResp.Body)
if err != nil { if err != nil {
out.Close() out.Close()
os.Remove(filePath) os.Remove(filePath)
return "", fmt.Errorf("failed to write file: %w", err) return "", err
} }
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024)) fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
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 return filePath, nil
} }
func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality string) (string, error) { func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality string) (string, error) {
fmt.Println("Attempting download via Lucida (Priority)...") return a.DownloadFromAfkarXYZ(amazonURL, outputDir, quality)
filePath, err := a.DownloadFromLucida(amazonURL, outputDir, quality)
if err == nil {
return filePath, nil
}
fmt.Printf("Lucida failed: %v\nTrying Double-Double as fallback...\n", err)
var lastError error
lastError = err
for _, region := range a.regions {
fmt.Printf("\nTrying region: %s...\n", region)
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=")
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=")
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
encodedURL := url.QueryEscape(amazonURL)
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
req, err := http.NewRequest("GET", submitURL, nil)
if err != nil {
lastError = fmt.Errorf("failed to create request: %w", err)
continue
}
req.Header.Set("User-Agent", a.getRandomUserAgent())
fmt.Println("Submitting download request...")
resp, err := a.client.Do(req)
if err != nil {
lastError = fmt.Errorf("failed to submit request: %w", err)
continue
}
if resp.StatusCode != 200 {
resp.Body.Close()
lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode)
continue
}
var submitResp DoubleDoubleSubmitResponse
if err := json.NewDecoder(resp.Body).Decode(&submitResp); err != nil {
resp.Body.Close()
lastError = fmt.Errorf("failed to decode submit response: %w", err)
continue
}
resp.Body.Close()
if !submitResp.Success || submitResp.ID == "" {
lastError = fmt.Errorf("submit request failed")
continue
}
downloadID := submitResp.ID
fmt.Printf("Download ID: %s\n", downloadID)
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
fmt.Println("Waiting for download to complete...")
maxWait := 300 * time.Second
elapsed := time.Duration(0)
pollInterval := 3 * time.Second
for elapsed < maxWait {
time.Sleep(pollInterval)
elapsed += pollInterval
statusReq, err := http.NewRequest("GET", statusURL, nil)
if err != nil {
continue
}
statusReq.Header.Set("User-Agent", a.getRandomUserAgent())
statusResp, err := a.client.Do(statusReq)
if err != nil {
fmt.Printf("\rStatus check failed, retrying...")
continue
}
if statusResp.StatusCode != 200 {
statusResp.Body.Close()
fmt.Printf("\rStatus check failed (status %d), retrying...", statusResp.StatusCode)
continue
}
var status DoubleDoubleStatusResponse
if err := json.NewDecoder(statusResp.Body).Decode(&status); err != nil {
statusResp.Body.Close()
fmt.Printf("\rInvalid JSON response, retrying...")
continue
}
statusResp.Body.Close()
if status.Status == "done" {
fmt.Println("\nDownload ready!")
fileURL := status.URL
if strings.HasPrefix(fileURL, "./") {
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
} else if strings.HasPrefix(fileURL, "/") {
fileURL = fmt.Sprintf("%s%s", baseURL, fileURL)
}
trackName := status.Current.Name
artist := status.Current.Artist
fmt.Printf("Downloading: %s - %s\n", artist, trackName)
downloadReq, err := http.NewRequest("GET", fileURL, nil)
if err != nil {
lastError = fmt.Errorf("failed to create download request: %w", err)
break
}
downloadReq.Header.Set("User-Agent", a.getRandomUserAgent())
fileResp, err := a.client.Do(downloadReq)
if err != nil {
lastError = fmt.Errorf("failed to download file: %w", err)
break
}
defer fileResp.Body.Close()
if fileResp.StatusCode != 200 {
lastError = fmt.Errorf("download failed with status %d", fileResp.StatusCode)
break
}
fileName := fmt.Sprintf("%s - %s.flac", artist, trackName)
for _, char := range `<>:"/\|?*` {
fileName = strings.ReplaceAll(fileName, string(char), "")
}
fileName = strings.TrimSpace(fileName)
filePath := filepath.Join(outputDir, fileName)
out, err := os.Create(filePath)
if err != nil {
lastError = fmt.Errorf("failed to create file: %w", err)
break
}
defer out.Close()
fmt.Println("Downloading...")
pw := NewProgressWriter(out)
_, err = io.Copy(pw, fileResp.Body)
if err != nil {
out.Close()
return "", fmt.Errorf("failed to write file: %w", err)
}
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
fmt.Println("Download complete!")
return filePath, nil
} else if status.Status == "error" {
errorMsg := status.FriendlyStatus
if errorMsg == "" {
errorMsg = "Unknown error"
}
lastError = fmt.Errorf("processing failed: %s", errorMsg)
break
} else {
friendlyStatus := status.FriendlyStatus
if friendlyStatus == "" {
friendlyStatus = status.Status
}
fmt.Printf("\r%s...", friendlyStatus)
}
}
if elapsed >= maxWait {
lastError = fmt.Errorf("download timeout")
fmt.Printf("\nError with %s region: %v\n", region, lastError)
continue
}
if lastError != nil {
fmt.Printf("\nError with %s region: %v\n", region, lastError)
}
}
return "", fmt.Errorf("all regions failed. Last error: %v", lastError)
} }
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) { func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
if outputDir != "." { if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil { if err := os.MkdirAll(outputDir, 0755); err != nil {
@@ -570,7 +268,13 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
} }
if spotifyTrackName != "" && spotifyArtistName != "" { if spotifyTrackName != "" && spotifyArtistName != "" {
expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, filenameFormat, includeTrackNumber, position, spotifyDiscNumber, false) 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) expectedPath := filepath.Join(outputDir, expectedFilename)
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 { if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
@@ -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) fmt.Printf("Using Amazon URL: %s\n", amazonURL)
filePath, err := a.DownloadFromService(amazonURL, outputDir, quality) filePath, err := a.DownloadFromService(amazonURL, outputDir, quality)
@@ -586,11 +326,28 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
return "", err 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 != "" { if spotifyTrackName != "" && spotifyArtistName != "" {
safeArtist := sanitizeFilename(spotifyArtistName) safeArtist := sanitizeFilename(spotifyArtistName)
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
if useFirstArtistOnly {
safeArtist = sanitizeFilename(GetFirstArtist(spotifyArtistName))
safeAlbumArtist = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
}
safeTitle := sanitizeFilename(spotifyTrackName) safeTitle := sanitizeFilename(spotifyTrackName)
safeAlbum := sanitizeFilename(spotifyAlbumName) safeAlbum := sanitizeFilename(spotifyAlbumName)
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
year := "" year := ""
if len(spotifyReleaseDate) >= 4 { 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}", safeAlbum)
newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist) newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist)
newFilename = strings.ReplaceAll(newFilename, "{year}", year) newFilename = strings.ReplaceAll(newFilename, "{year}", year)
newFilename = strings.ReplaceAll(newFilename, "{date}", SanitizeFilename(spotifyReleaseDate))
if spotifyDiscNumber > 0 { if spotifyDiscNumber > 0 {
newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber)) 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) newFilePath := filepath.Join(outputDir, newFilename)
if err := os.Rename(filePath, newFilePath); err != nil { if err := os.Rename(filePath, newFilePath); err != nil {
@@ -683,25 +445,41 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
Copyright: spotifyCopyright, Copyright: spotifyCopyright,
Publisher: spotifyPublisher, Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC", 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) fmt.Printf("Warning: Failed to embed metadata: %v\n", err)
} else { } else {
fmt.Println("Metadata embedded successfully") 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("Done")
fmt.Println("✓ Downloaded successfully from Amazon Music") fmt.Println("✓ Downloaded successfully from Amazon Music")
return filePath, nil return filePath, nil
} }
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) { func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string,
useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool,
) (string, error) {
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID) amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
if err != nil { if err != nil {
return "", err return "", err
} }
return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL) return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, useFirstArtistOnly, useSingleGenre, embedGenre)
} }
+95 -24
View File
@@ -4,6 +4,10 @@ import (
"fmt" "fmt"
"math" "math"
"os" "os"
"os/exec"
"strconv"
"strings"
"time"
"github.com/go-flac/go-flac" "github.com/go-flac/go-flac"
mewflac "github.com/mewkiz/flac" mewflac "github.com/mewkiz/flac"
@@ -17,6 +21,7 @@ type AnalysisResult struct {
BitsPerSample uint8 `json:"bits_per_sample"` BitsPerSample uint8 `json:"bits_per_sample"`
TotalSamples uint64 `json:"total_samples"` TotalSamples uint64 `json:"total_samples"`
Duration float64 `json:"duration"` Duration float64 `json:"duration"`
Bitrate int `json:"bit_rate"`
BitDepth string `json:"bit_depth"` BitDepth string `json:"bit_depth"`
DynamicRange float64 `json:"dynamic_range"` DynamicRange float64 `json:"dynamic_range"`
PeakAmplitude float64 `json:"peak_amplitude"` 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) 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 { 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 { 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{ lines := strings.Split(strings.TrimSpace(string(output)), "\n")
FilePath: filepath, if len(lines) < 4 {
FileSize: fileInfo.Size(), return nil, fmt.Errorf("unexpected ffprobe output: %s", string(output))
} }
if len(f.Meta) > 0 { res := &AnalysisResult{
streamInfo := f.Meta[0] FilePath: filePath,
if streamInfo.Type == flac.StreamInfo { }
data := streamInfo.Data
if len(data) >= 18 {
result.SampleRate = uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4
result.BitsPerSample = ((data[12]&0x01)<<4 | data[13]>>4) + 1
result.TotalSamples = uint64(data[13]&0x0F)<<32 |
uint64(data[14])<<24 |
uint64(data[15])<<16 |
uint64(data[16])<<8 |
uint64(data[17])
if result.SampleRate > 0 { if info, err := os.Stat(filePath); err == nil {
result.Duration = float64(result.TotalSamples) / float64(result.SampleRate) 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}", safeAlbum)
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist) filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year) filename = strings.ReplaceAll(filename, "{year}", year)
filename = strings.ReplaceAll(filename, "{date}", sanitizeFilename(releaseDate))
if discNumber > 0 { if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) 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 ( import (
"archive/tar" "archive/tar"
"archive/zip" "archive/zip"
"encoding/base64"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -18,14 +18,6 @@ import (
"github.com/ulikunitz/xz" "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 { func ValidateExecutable(path string) error {
cleanedPath := filepath.Clean(path) cleanedPath := filepath.Clean(path)
if cleanedPath == "" { if cleanedPath == "" {
@@ -65,13 +57,6 @@ func ValidateExecutable(path string) error {
return nil return nil
} }
const (
ffmpegWindowsURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3Qtd2luNjQtZ3BsLnppcA=="
ffmpegLinuxURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3QtbGludXg2NC1ncGwudGFyLnh6"
ffmpegMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS96aXA="
ffprobeMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS9mZnByb2JlL3ppcA=="
)
func GetFFmpegDir() (string, error) { func GetFFmpegDir() (string, error) {
homeDir, err := os.UserHomeDir() homeDir, err := os.UserHomeDir()
if err != nil { if err != nil {
@@ -91,7 +76,17 @@ func GetFFmpegPath() (string, error) {
ffmpegName = "ffmpeg.exe" 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) { func GetFFprobePath() (string, error) {
@@ -105,12 +100,17 @@ func GetFFprobePath() (string, error) {
ffprobeName = "ffprobe.exe" ffprobeName = "ffprobe.exe"
} }
ffprobePath := filepath.Join(ffmpegDir, ffprobeName) localPath := filepath.Join(ffmpegDir, ffprobeName)
if _, err := os.Stat(ffprobePath); err == nil { if _, err := os.Stat(localPath); err == nil {
return ffprobePath, 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) { func IsFFprobeInstalled() (bool, error) {
@@ -146,6 +146,11 @@ func IsFFmpegInstalled() (bool, error) {
return err == nil, nil 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 { func DownloadFFmpeg(progressCallback func(int)) error {
SetDownloadProgress(0) SetDownloadProgress(0)
@@ -166,54 +171,51 @@ func DownloadFFmpeg(progressCallback func(int)) error {
ffmpegInstalled, _ := IsFFmpegInstalled() ffmpegInstalled, _ := IsFFmpegInstalled()
ffprobeInstalled, _ := IsFFprobeInstalled() ffprobeInstalled, _ := IsFFprobeInstalled()
if !ffmpegInstalled && !ffprobeInstalled { isARM := runtime.GOARCH == "arm64"
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL) var macFFmpegURLs []string
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL) var macFFprobeURLs []string
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 50); err != nil {
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 return err
} }
if err := downloadWithFallback(macFFprobeURLs, ffmpegDir, progressCallback, 50, 100); err != nil {
ffprobeURL, _ := decodeBase64(ffprobeMacOSURL) return err
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)
} }
} else if !ffmpegInstalled { } else if !ffmpegInstalled {
if err := downloadWithFallback(macFFmpegURLs, ffmpegDir, progressCallback, 0, 100); err != nil {
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 100); err != nil {
return err return err
} }
} else if !ffprobeInstalled { } else if !ffprobeInstalled {
if err := downloadWithFallback(macFFprobeURLs, ffmpegDir, progressCallback, 0, 100); err != nil {
ffprobeURL, _ := decodeBase64(ffprobeMacOSURL) return err
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)
} }
} }
return nil return nil
} }
var encodedURL string var url string
switch runtime.GOOS { switch runtime.GOOS {
case "windows": case "windows":
encodedURL = ffmpegWindowsURL url = ffmpegWindowsURL
case "linux": case "linux":
encodedURL = ffmpegLinuxURL url = ffmpegLinuxURL
default: default:
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS) 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) fmt.Printf("[FFmpeg] Downloading from: %s\n", url)
if err := downloadAndExtract(url, ffmpegDir, progressCallback, 0, 100); err != nil { if err := downloadAndExtract(url, ffmpegDir, progressCallback, 0, 100); err != nil {
return err return err
} }
@@ -221,6 +223,20 @@ func DownloadFFmpeg(progressCallback func(int)) error {
return nil 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 { func downloadAndExtract(url, destDir string, progressCallback func(int), progressStart, progressEnd int) error {
tmpFile, err := os.CreateTemp("", "ffmpeg-*") tmpFile, err := os.CreateTemp("", "ffmpeg-*")
@@ -230,7 +246,14 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres
defer os.Remove(tmpFile.Name()) defer os.Remove(tmpFile.Name())
defer tmpFile.Close() 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 { if err != nil {
return fmt.Errorf("failed to download: %w", err) 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}", sanitizeFilenameForRename(metadata.Album))
result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist)) result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist))
result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year)) result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year))
result = strings.ReplaceAll(result, "{date}", sanitizeFilenameForRename(metadata.Year))
if metadata.TrackNumber > 0 { if metadata.TrackNumber > 0 {
result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber)) result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber))
+28 -8
View File
@@ -9,12 +9,15 @@ import (
"unicode/utf8" "unicode/utf8"
) )
func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool) string { func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool) string {
safeTitle := sanitizeFilename(trackName) safeTitle := SanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName) safeArtist := SanitizeFilename(artistName)
safeAlbum := sanitizeFilename(albumName) safeAlbum := SanitizeFilename(albumName)
safeAlbumArtist := sanitizeFilename(albumArtist) safeAlbumArtist := SanitizeFilename(albumArtist)
safePlaylist := SanitizeFilename(playlistName)
safeCreator := SanitizeFilename(playlistOwner)
year := "" year := ""
if len(releaseDate) >= 4 { if len(releaseDate) >= 4 {
@@ -30,6 +33,9 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas
filename = strings.ReplaceAll(filename, "{album}", safeAlbum) filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist) filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year) filename = strings.ReplaceAll(filename, "{year}", year)
filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate))
filename = strings.ReplaceAll(filename, "{playlist}", safePlaylist)
filename = strings.ReplaceAll(filename, "{creator}", safeCreator)
if discNumber > 0 { if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
@@ -64,7 +70,7 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas
return filename + ".flac" return filename + ".flac"
} }
func sanitizeFilename(name string) string { func SanitizeFilename(name string) string {
sanitized := strings.ReplaceAll(name, "/", " ") sanitized := strings.ReplaceAll(name, "/", " ")
@@ -113,6 +119,19 @@ func sanitizeFilename(name string) string {
return sanitized 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 { func NormalizePath(folderPath string) string {
return strings.ReplaceAll(folderPath, "/", string(filepath.Separator)) return strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
@@ -148,7 +167,8 @@ func SanitizeFolderPath(folderPath string) string {
return strings.Join(sanitizedParts, sep) return strings.Join(sanitizedParts, sep)
} }
func sanitizeFolderName(name string) string { func sanitizeFolderName(name string) string { return SanitizeFilename(name) }
return sanitizeFilename(name) func sanitizeFilename(name string) string {
return SanitizeFilename(name)
} }
+23
View File
@@ -74,3 +74,26 @@ func SelectFileDialog(ctx context.Context) (string, error) {
return selectedFile, nil return selectedFile, nil
} }
func SelectImageVideoDialog(ctx context.Context) ([]string, error) {
options := wailsRuntime.OpenDialogOptions{
Title: "Select Image or Video",
Filters: []wailsRuntime.FileFilter{
{
DisplayName: "Supported Files (*.jpg, *.png, *.mp4, *.mov, ...)",
Pattern: "*.jpg;*.jpeg;*.png;*.gif;*.webp;*.mp4;*.mkv;*.webm;*.mov",
},
{
DisplayName: "All Files (*.*)",
Pattern: "*.*",
},
},
}
selectedPaths, err := wailsRuntime.OpenMultipleFilesDialog(ctx, options)
if err != nil {
return nil, err
}
return selectedPaths, nil
}
+178 -1
View File
@@ -75,7 +75,10 @@ func AddHistoryItem(item HistoryItem, appName string) error {
} }
} }
return historyDB.Update(func(tx *bolt.Tx) error { return historyDB.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(historyBucket)) b, err := tx.CreateBucketIfNotExists([]byte(historyBucket))
if err != nil {
return err
}
id, _ := b.NextSequence() id, _ := b.NextSequence()
item.ID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), id) item.ID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), id)
@@ -147,3 +150,177 @@ func ClearHistory(appName string) error {
return tx.DeleteBucket([]byte(historyBucket)) return tx.DeleteBucket([]byte(historyBucket))
}) })
} }
type FetchHistoryItem struct {
ID string `json:"id"`
URL string `json:"url"`
Type string `json:"type"`
Name string `json:"name"`
Info string `json:"info"`
Image string `json:"image"`
Data string `json:"data"`
Timestamp int64 `json:"timestamp"`
}
const (
fetchHistoryBucket = "FetchHistory"
)
func AddFetchHistoryItem(item FetchHistoryItem, appName string) error {
if historyDB == nil {
if err := InitHistoryDB(appName); err != nil {
return err
}
}
return historyDB.Update(func(tx *bolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte(fetchHistoryBucket))
if err != nil {
return err
}
id, _ := b.NextSequence()
if item.URL != "" {
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
var existing FetchHistoryItem
if err := json.Unmarshal(v, &existing); err == nil {
if existing.URL == item.URL && existing.Type == item.Type {
if err := b.Delete(k); err != nil {
return err
}
}
}
}
}
item.ID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), id)
item.Timestamp = time.Now().Unix()
buf, err := json.Marshal(item)
if err != nil {
return err
}
if b.Stats().KeyN >= maxHistory {
c := b.Cursor()
toDelete := maxHistory / 20
if toDelete < 1 {
toDelete = 1
}
count := 0
for k, _ := c.First(); k != nil && count < toDelete; k, _ = c.Next() {
if err := b.Delete(k); err != nil {
return err
}
count++
}
}
return b.Put([]byte(item.ID), buf)
})
}
func GetFetchHistoryItems(appName string) ([]FetchHistoryItem, error) {
if historyDB == nil {
if err := InitHistoryDB(appName); err != nil {
return nil, err
}
}
var items []FetchHistoryItem
err := historyDB.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(fetchHistoryBucket))
if b == nil {
return nil
}
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
var item FetchHistoryItem
if err := json.Unmarshal(v, &item); err == nil {
items = append(items, item)
}
}
return nil
})
sort.Slice(items, func(i, j int) bool {
return items[i].Timestamp > items[j].Timestamp
})
return items, err
}
func ClearFetchHistory(appName string) error {
if historyDB == nil {
if err := InitHistoryDB(appName); err != nil {
return err
}
}
return historyDB.Update(func(tx *bolt.Tx) error {
return tx.DeleteBucket([]byte(fetchHistoryBucket))
})
}
func ClearFetchHistoryByType(itemType string, appName string) error {
if historyDB == nil {
if err := InitHistoryDB(appName); err != nil {
return err
}
}
return historyDB.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(fetchHistoryBucket))
if b == nil {
return nil
}
var keysToDelete [][]byte
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
var item FetchHistoryItem
if err := json.Unmarshal(v, &item); err == nil {
if item.Type == itemType {
keysToDelete = append(keysToDelete, k)
}
}
}
for _, k := range keysToDelete {
if err := b.Delete(k); err != nil {
return err
}
}
return nil
})
}
func DeleteHistoryItem(id string, appName string) error {
if historyDB == nil {
if err := InitHistoryDB(appName); err != nil {
return err
}
}
return historyDB.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(historyBucket))
if b == nil {
return nil
}
return b.Delete([]byte(id))
})
}
func DeleteFetchHistoryItem(id string, appName string) error {
if historyDB == nil {
if err := InitHistoryDB(appName); err != nil {
return err
}
}
return historyDB.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(fetchHistoryBucket))
if b == nil {
return nil
}
return b.Delete([]byte(id))
})
}
+76 -7
View File
@@ -1,7 +1,6 @@
package backend package backend
import ( import (
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -38,6 +37,17 @@ type LyricsResponse struct {
Lines []LyricsLine `json:"lines"` 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 { type LyricsDownloadRequest struct {
SpotifyID string `json:"spotify_id"` SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"` TrackName string `json:"track_name"`
@@ -73,9 +83,7 @@ func NewLyricsClient() *LyricsClient {
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string, duration int) (*LyricsResponse, error) { func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string, duration int) (*LyricsResponse, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9nZXQ/YXJ0aXN0X25hbWU9") apiURL := fmt.Sprintf("https://lrclib.net/api/get?artist_name=%s&track_name=%s",
apiURL := fmt.Sprintf("%s%s&track_name=%s",
string(apiBase),
url.QueryEscape(artistName), url.QueryEscape(artistName),
url.QueryEscape(trackName)) url.QueryEscape(trackName))
@@ -167,8 +175,7 @@ func lrcTimestampToMs(timestamp string) int64 {
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) (*LyricsResponse, error) { func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) (*LyricsResponse, error) {
query := fmt.Sprintf("%s %s", artistName, trackName) query := fmt.Sprintf("%s %s", artistName, trackName)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9zZWFyY2g/cT0=") apiURL := fmt.Sprintf("https://lrclib.net/api/search?q=%s", url.QueryEscape(query))
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(query))
resp, err := c.httpClient.Get(apiURL) resp, err := c.httpClient.Get(apiURL)
if err != nil { if err != nil {
@@ -212,6 +219,61 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string)
return c.convertLRCLibToLyricsResponse(best), nil 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 { func simplifyTrackName(name string) string {
if idx := strings.Index(name, "("); idx > 0 { 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) { 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 { if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB", nil 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}", safeAlbum)
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist) filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year) filename = strings.ReplaceAll(filename, "{year}", year)
filename = strings.ReplaceAll(filename, "{date}", sanitizeFilename(releaseDate))
if discNumber > 0 { if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
+33 -13
View File
@@ -31,6 +31,8 @@ type Metadata struct {
Publisher string Publisher string
Lyrics string Lyrics string
Description string Description string
ISRC string
Genre string
} }
func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error { 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) _ = cmt.Add("DESCRIPTION", metadata.Description)
} }
if metadata.ISRC != "" {
_ = cmt.Add("ISRC", metadata.ISRC)
}
if metadata.Genre != "" {
_ = cmt.Add("GENRE", metadata.Genre)
}
if metadata.Lyrics != "" { if metadata.Lyrics != "" {
_ = cmt.Add("LYRICS", metadata.Lyrics) _ = cmt.Add("LYRICS", metadata.Lyrics)
} }
@@ -504,6 +514,13 @@ func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error {
return nil 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)) ext := strings.ToLower(pathfilepath.Ext(filepath))
switch ext { switch ext {
case ".mp3": case ".mp3":
@@ -635,27 +652,22 @@ func validateLyricsDuration(lyrics string, filepath string) (string, error) {
if strings.HasPrefix(trimmedLine, "[") { if strings.HasPrefix(trimmedLine, "[") {
if strings.Index(trimmedLine, ":") > 0 {
validLines = append(validLines, line)
continue
}
closeBracket := strings.Index(trimmedLine, "]") closeBracket := strings.Index(trimmedLine, "]")
if closeBracket > 0 { if closeBracket > 0 {
timestampStr := trimmedLine[1:closeBracket] timestampStr := trimmedLine[1:closeBracket]
ms := parseLRCTimestamp(timestampStr) ms := parseLRCTimestamp(timestampStr)
if ms >= 0 && ms <= durationMs { if ms >= 0 {
if ms <= durationMs {
validLines = append(validLines, line) validLines = append(validLines, line)
} else {
fmt.Printf("[ValidateLyrics] Filtered out line with timestamp %s (exceeds duration %d ms): %s\n", timestampStr, durationMs, trimmedLine)
}
} else { } else {
fmt.Printf("[ValidateLyrics] Filtered out line with timestamp %s (exceeds duration %d ms): %s\n", timestampStr, durationMs, trimmedLine) validLines = append(validLines, line)
} }
} else { continue
validLines = append(validLines, line)
} }
} else { } else {
@@ -858,6 +870,11 @@ func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) er
tag.AddTextFrame("TPUB", id3v2.EncodingUTF8, metadata.Publisher) tag.AddTextFrame("TPUB", id3v2.EncodingUTF8, metadata.Publisher)
} }
if metadata.ISRC != "" {
tag.DeleteFrames("TSRC")
tag.AddTextFrame("TSRC", id3v2.EncodingUTF8, metadata.ISRC)
}
if coverPath != "" && fileExists(coverPath) { if coverPath != "" && fileExists(coverPath) {
tag.DeleteFrames(tag.CommonID("Attached picture")) tag.DeleteFrames(tag.CommonID("Attached picture"))
@@ -941,6 +958,9 @@ func embedMetadataToM4A(filePath string, metadata Metadata, coverPath string) er
if metadata.Publisher != "" { if metadata.Publisher != "" {
args = append(args, "-metadata", "publisher="+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) tmpOutputFile := strings.TrimSuffix(filePath, pathfilepath.Ext(filePath)) + ".tmp" + pathfilepath.Ext(filePath)
defer func() { 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"` TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"` ArtistName string `json:"artist_name"`
AlbumName string `json:"album_name"` AlbumName string `json:"album_name"`
ISRC string `json:"isrc"` SpotifyID string `json:"spotify_id"`
Status DownloadStatus `json:"status"` Status DownloadStatus `json:"status"`
Progress float64 `json:"progress"` Progress float64 `json:"progress"`
TotalSize float64 `json:"total_size"` TotalSize float64 `json:"total_size"`
@@ -184,7 +184,7 @@ func (pw *ProgressWriter) GetTotal() int64 {
return pw.total return pw.total
} }
func AddToQueue(id, trackName, artistName, albumName, isrc string) { func AddToQueue(id, trackName, artistName, albumName, spotifyID string) {
downloadQueueLock.Lock() downloadQueueLock.Lock()
defer downloadQueueLock.Unlock() defer downloadQueueLock.Unlock()
@@ -193,7 +193,7 @@ func AddToQueue(id, trackName, artistName, albumName, isrc string) {
TrackName: trackName, TrackName: trackName,
ArtistName: artistName, ArtistName: artistName,
AlbumName: albumName, AlbumName: albumName,
ISRC: isrc, SpotifyID: spotifyID,
Status: StatusQueued, Status: StatusQueued,
Progress: 0, Progress: 0,
TotalSize: 0, TotalSize: 0,
+150 -86
View File
@@ -1,10 +1,10 @@
package backend package backend
import ( import (
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"math/rand"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -77,10 +77,9 @@ func NewQobuzDownloader() *QobuzDownloader {
} }
} }
func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) { func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
apiBase := "https://www.qobuz.com/api.json/0.2/track/search?query="
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") url := fmt.Sprintf("%s%s&limit=1&app_id=%s", apiBase, isrc, q.appID)
url := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, q.appID)
resp, err := q.client.Get(url) resp, err := q.client.Get(url)
if err != nil { if err != nil {
@@ -119,104 +118,123 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
return &searchResp.Tracks.Items[0], nil return &searchResp.Tracks.Items[0], nil
} }
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) { func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, quality string) (string, error) {
apiURL := fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
qualityCode := quality resp, err := q.client.Get(apiURL)
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)
if err != nil { if err != nil {
return "", fmt.Errorf("all APIs failed to get download URL: %w", err) return "", err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("status %d", resp.StatusCode)
fmt.Printf("Fallback API #2 error response (status %d): %s\n", resp.StatusCode, string(body))
return "", fmt.Errorf("all APIs returned non-200 status")
} }
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err) return "", err
} }
if len(body) == 0 { if len(body) == 0 {
return "", fmt.Errorf("API returned empty response") return "", fmt.Errorf("empty body")
} }
fmt.Printf("Fallback API #2 response: %s\n", string(body))
var streamResp QobuzStreamResponse var streamResp QobuzStreamResponse
if err := json.Unmarshal(body, &streamResp); err != nil { if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
return streamResp.URL, nil
}
bodyStr := string(body) var nestedResp struct {
if len(bodyStr) > 200 { Data struct {
bodyStr = bodyStr[:200] + "..." 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 == "" { url, err := downloadFunc(qualityCode)
return "", fmt.Errorf("no download URL available from any API") if err == nil {
return url, nil
} }
fmt.Printf("✓ Got download URL from Fallback API #2\n") currentQuality := qualityCode
return streamResp.URL, nil
if currentQuality == "27" && allowFallback {
fmt.Printf("⚠ Download with quality 27 failed, trying fallback to 7 (24-bit Standard)...\n")
url, err := downloadFunc("7")
if err == nil {
fmt.Println("✓ Success with fallback quality 7")
return url, nil
}
currentQuality = "7"
}
if currentQuality == "7" && allowFallback {
fmt.Printf("⚠ Download with quality 7 failed, trying fallback to 6 (16-bit Lossless)...\n")
url, err := downloadFunc("6")
if err == nil {
fmt.Println("✓ Success with fallback quality 6")
return url, nil
}
}
return "", fmt.Errorf("all APIs and fallbacks failed. Last error: %v", err)
} }
func (q *QobuzDownloader) DownloadFile(url, filepath string) error { func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
@@ -300,6 +318,7 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t
filename = strings.ReplaceAll(filename, "{album}", album) filename = strings.ReplaceAll(filename, "{album}", album)
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist) filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
filename = strings.ReplaceAll(filename, "{year}", year) filename = strings.ReplaceAll(filename, "{year}", year)
filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate))
if discNumber > 0 { if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) 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" 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) 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 outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil { if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("failed to create output directory: %w", err) return "", fmt.Errorf("failed to create output directory: %w", err)
} }
} }
track, err := q.SearchByISRC(deezerISRC) track, err := q.searchByISRC(deezerISRC)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -362,7 +413,7 @@ func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenam
fmt.Printf("Quality: %s\n", qualityInfo) fmt.Printf("Quality: %s\n", qualityInfo)
fmt.Println("Getting download URL...") fmt.Println("Getting download URL...")
downloadURL, err := q.GetDownloadURL(track.ID, quality) downloadURL, err := q.GetDownloadURL(track.ID, quality, allowFallback)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to get download URL: %w", err) return "", fmt.Errorf("failed to get download URL: %w", err)
} }
@@ -378,9 +429,15 @@ func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenam
fmt.Printf("Download URL obtained: %s\n", urlPreview) fmt.Printf("Download URL obtained: %s\n", urlPreview)
safeArtist := sanitizeFilename(artists) safeArtist := sanitizeFilename(artists)
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
if useFirstArtistOnly {
safeArtist = sanitizeFilename(GetFirstArtist(artists))
safeAlbumArtist = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
}
safeTitle := sanitizeFilename(trackTitle) safeTitle := sanitizeFilename(trackTitle)
safeAlbum := sanitizeFilename(albumTitle) safeAlbum := sanitizeFilename(albumTitle)
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
filename := buildQobuzFilename(safeTitle, safeArtist, safeAlbum, safeAlbumArtist, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) filename := buildQobuzFilename(safeTitle, safeArtist, safeAlbum, safeAlbumArtist, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
filepath := filepath.Join(outputDir, filename) filepath := filepath.Join(outputDir, filename)
@@ -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...") fmt.Println("Embedding metadata and cover art...")
trackNumberToEmbed := spotifyTrackNumber trackNumberToEmbed := spotifyTrackNumber
@@ -432,6 +494,8 @@ func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenam
Copyright: spotifyCopyright, Copyright: spotifyCopyright,
Publisher: spotifyPublisher, Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC", Description: "https://github.com/afkarxyz/SpotiFLAC",
ISRC: deezerISRC,
Genre: mbMeta.Genre,
} }
if err := EmbedMetadata(filepath, metadata, coverPath); err != nil { if err := EmbedMetadata(filepath, metadata, coverPath); err != nil {
+34 -19
View File
@@ -1,7 +1,6 @@
package backend package backend
import ( import (
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -21,6 +20,7 @@ type SongLinkClient struct {
type SongLinkURLs struct { type SongLinkURLs struct {
TidalURL string `json:"tidal_url"` TidalURL string `json:"tidal_url"`
AmazonURL string `json:"amazon_url"` AmazonURL string `json:"amazon_url"`
ISRC string `json:"isrc"`
} }
type TrackAvailability struct { type TrackAvailability struct {
@@ -28,9 +28,11 @@ type TrackAvailability struct {
Tidal bool `json:"tidal"` Tidal bool `json:"tidal"`
Amazon bool `json:"amazon"` Amazon bool `json:"amazon"`
Qobuz bool `json:"qobuz"` Qobuz bool `json:"qobuz"`
Deezer bool `json:"deezer"`
TidalURL string `json:"tidal_url,omitempty"` TidalURL string `json:"tidal_url,omitempty"`
AmazonURL string `json:"amazon_url,omitempty"` AmazonURL string `json:"amazon_url,omitempty"`
QobuzURL string `json:"qobuz_url,omitempty"` QobuzURL string `json:"qobuz_url,omitempty"`
DeezerURL string `json:"deezer_url,omitempty"`
} }
func NewSongLinkClient() *SongLinkClient { 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() now := time.Now()
if now.Sub(s.apiCallResetTime) >= time.Minute { 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("https://open.spotify.com/track/%s", spotifyTrackID)
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(spotifyURL))
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
if region != "" {
apiURL += fmt.Sprintf("&userCountry=%s", region)
}
req, err := http.NewRequest("GET", apiURL, nil) req, err := http.NewRequest("GET", apiURL, nil)
if err != nil { if err != nil {
@@ -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 == "" { if urls.TidalURL == "" && urls.AmazonURL == "" {
return nil, fmt.Errorf("no streaming URLs found") return nil, fmt.Errorf("no streaming URLs found")
} }
@@ -161,7 +171,7 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
return urls, nil return urls, nil
} }
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) { func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string) (*TrackAvailability, error) {
now := time.Now() now := time.Now()
if now.Sub(s.apiCallResetTime) >= time.Minute { 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("https://open.spotify.com/track/%s", spotifyTrackID)
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(spotifyURL))
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
req, err := http.NewRequest("GET", apiURL, nil) req, err := http.NewRequest("GET", apiURL, nil)
if err != nil { if err != nil {
@@ -273,8 +281,10 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
deezerURL := deezerLink.URL deezerURL := deezerLink.URL
availability.Deezer = true
availability.DeezerURL = deezerURL
deezerISRC, err := GetDeezerISRC(deezerURL) deezerISRC, err := getDeezerISRC(deezerURL)
if err == nil && deezerISRC != "" { if err == nil && deezerISRC != "" {
qobuzAvailable := checkQobuzAvailability(deezerISRC) qobuzAvailable := checkQobuzAvailability(deezerISRC)
availability.Qobuz = qobuzAvailable availability.Qobuz = qobuzAvailable
@@ -288,8 +298,7 @@ func checkQobuzAvailability(isrc string) bool {
client := &http.Client{Timeout: 10 * time.Second} client := &http.Client{Timeout: 10 * time.Second}
appID := "798273057" appID := "798273057"
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/track/search?query=%s&limit=1&app_id=%s", isrc, appID)
searchURL := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, appID)
resp, err := client.Get(searchURL) resp, err := client.Get(searchURL)
if err != nil { if err != nil {
@@ -341,11 +350,9 @@ func (s *SongLinkClient) GetDeezerURLFromSpotify(spotifyTrackID string) (string,
} }
} }
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(spotifyURL))
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
req, err := http.NewRequest("GET", apiURL, nil) req, err := http.NewRequest("GET", apiURL, nil)
if err != nil { if err != nil {
@@ -404,7 +411,7 @@ func (s *SongLinkClient) GetDeezerURLFromSpotify(spotifyTrackID string) (string,
return deezerURL, nil return deezerURL, nil
} }
func GetDeezerISRC(deezerURL string) (string, error) { func getDeezerISRC(deezerURL string) (string, error) {
var trackID string var trackID string
if strings.Contains(deezerURL, "/track/") { 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) fmt.Printf("Found ISRC from Deezer: %s (track: %s)\n", deezerTrack.ISRC, deezerTrack.Title)
return deezerTrack.ISRC, nil 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 ( import (
"bytes" "bytes"
"encoding/base32"
"encoding/base64" "encoding/base64"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "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) { func (c *SpotifyClient) generateTOTP() (string, int, error) {
version, secretList := c.getTOTPSecret()
transformed := make([]byte, len(secretList)) secret := "GM3TMMJTGYZTQNZVGM4DINJZHA4TGOBYGMZTCMRTGEYDSMJRHE4TEOBUG4YTCMRUGQ4DQOJUGQYTAMRRGA2TCMJSHE3TCMBY"
for i, b := range secretList { version := 61
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, "=")
key, err := otp.NewKeyFromURL(fmt.Sprintf("otpauth://totp/secret?secret=%s", secret)) key, err := otp.NewKeyFromURL(fmt.Sprintf("otpauth://totp/secret?secret=%s", secret))
if err != nil { if err != nil {
@@ -88,11 +57,6 @@ func (c *SpotifyClient) generateTOTP() (string, int, error) {
return totpCode, version, nil return totpCode, version, nil
} }
func base32Encode(data []byte) string {
b32 := base32.StdEncoding.WithPadding(base32.NoPadding)
return b32.EncodeToString(data)
}
func (c *SpotifyClient) getAccessToken() error { func (c *SpotifyClient) getAccessToken() error {
totpCode, version, err := c.generateTOTP() totpCode, version, err := c.generateTOTP()
if err != nil { if err != nil {
@@ -112,7 +76,7 @@ func (c *SpotifyClient) getAccessToken() error {
q.Add("totpServer", totpCode) q.Add("totpServer", totpCode)
req.URL.RawQuery = q.Encode() req.URL.RawQuery = q.Encode()
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
req.Header.Set("Content-Type", "application/json;charset=UTF-8") req.Header.Set("Content-Type", "application/json;charset=UTF-8")
resp, err := c.client.Do(req) resp, err := c.client.Do(req)
@@ -149,7 +113,7 @@ func (c *SpotifyClient) getSessionInfo() error {
return err return err
} }
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
for name, value := range c.cookies { for name, value := range c.cookies {
req.AddCookie(&http.Cookie{Name: name, Value: value}) req.AddCookie(&http.Cookie{Name: name, Value: value})
@@ -230,7 +194,7 @@ func (c *SpotifyClient) getClientToken() error {
req.Header.Set("Authority", "clienttoken.spotify.com") req.Header.Set("Authority", "clienttoken.spotify.com")
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
resp, err := c.client.Do(req) resp, err := c.client.Do(req)
if err != nil { if err != nil {
@@ -288,7 +252,7 @@ func (c *SpotifyClient) Query(payload map[string]interface{}) (map[string]interf
req.Header.Set("Client-Token", c.clientToken) req.Header.Set("Client-Token", c.clientToken)
req.Header.Set("Spotify-App-Version", c.clientVersion) req.Header.Set("Spotify-App-Version", c.clientVersion)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
resp, err := c.client.Do(req) resp, err := c.client.Do(req)
if err != nil { 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{} { func extractArtists(artistsData map[string]interface{}) []map[string]interface{} {
items := getSlice(artistsData, "items") items := getSlice(artistsData, "items")
if items == nil {
return []map[string]interface{}{}
}
artists := []map[string]interface{}{} artists := []map[string]interface{}{}
for _, item := range items { 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{} { func extractCoverImage(coverData map[string]interface{}) map[string]interface{} {
if coverData == nil || len(coverData) == 0 { if len(coverData) == 0 {
return nil 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 return nil
} }
@@ -532,7 +493,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
} }
var albumFetchDataMap map[string]interface{} var albumFetchDataMap map[string]interface{}
if len(albumFetchData) > 0 && albumFetchData[0] != nil { if len(albumFetchData) > 0 {
albumFetchDataMap = albumFetchData[0] albumFetchDataMap = albumFetchData[0]
} }
@@ -541,39 +502,35 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
if len(artists) == 0 { if len(artists) == 0 {
artists = []map[string]interface{}{} artists = []map[string]interface{}{}
firstArtistItems := getSlice(getMap(trackData, "firstArtist"), "items") firstArtistItems := getSlice(getMap(trackData, "firstArtist"), "items")
if firstArtistItems != nil { for _, item := range firstArtistItems {
for _, item := range firstArtistItems { itemMap, ok := item.(map[string]interface{})
itemMap, ok := item.(map[string]interface{}) if !ok {
if !ok { continue
continue }
} if profile, exists := itemMap["profile"]; exists {
if profile, exists := itemMap["profile"]; exists { profileMap, ok := profile.(map[string]interface{})
profileMap, ok := profile.(map[string]interface{}) if ok {
if ok { artistInfo := map[string]interface{}{
artistInfo := map[string]interface{}{ "name": getString(profileMap, "name"),
"name": getString(profileMap, "name"),
}
artists = append(artists, artistInfo)
} }
artists = append(artists, artistInfo)
} }
} }
} }
otherArtistItems := getSlice(getMap(trackData, "otherArtists"), "items") otherArtistItems := getSlice(getMap(trackData, "otherArtists"), "items")
if otherArtistItems != nil { for _, item := range otherArtistItems {
for _, item := range otherArtistItems { itemMap, ok := item.(map[string]interface{})
itemMap, ok := item.(map[string]interface{}) if !ok {
if !ok { continue
continue }
} if profile, exists := itemMap["profile"]; exists {
if profile, exists := itemMap["profile"]; exists { profileMap, ok := profile.(map[string]interface{})
profileMap, ok := profile.(map[string]interface{}) if ok {
if ok { artistInfo := map[string]interface{}{
artistInfo := map[string]interface{}{ "name": getString(profileMap, "name"),
"name": getString(profileMap, "name"),
}
artists = append(artists, artistInfo)
} }
artists = append(artists, artistInfo)
} }
} }
} }
@@ -710,6 +667,9 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
} }
albumArtistsString = strings.Join(albumArtistNames, ", ") albumArtistsString = strings.Join(albumArtistNames, ", ")
} }
if albumArtistsString == "" {
albumArtistsString = getString(albumUnionData, "artists")
}
albumLabel = getString(albumUnionData, "label") albumLabel = getString(albumUnionData, "label")
} }
} }
@@ -767,23 +727,76 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
if discNumber == 0 { if discNumber == 0 {
discNumber = 1 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 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) totalDiscs = discInfo["totalDiscs"].(int)
} }
contentRating := getMap(trackData, "contentRating")
isExplicit := getString(contentRating, "label") == "EXPLICIT"
filtered := map[string]interface{}{ filtered := map[string]interface{}{
"id": getString(trackData, "id"), "id": getString(trackData, "id"),
"name": getString(trackData, "name"), "name": getString(trackData, "name"),
"artists": artistsString, "artists": artistsString,
"album": albumInfo, "album": albumInfo,
"duration": durationString, "duration": durationString,
"track": int(getFloat64(trackData, "trackNumber")), "track": int(getFloat64(trackData, "trackNumber")),
"disc": discNumber, "disc": discNumber,
"discs": totalDiscs, "discs": totalDiscs,
"copyright": copyrightString, "copyright": copyrightString,
"plays": getString(trackData, "playcount"), "plays": getString(trackData, "playcount"),
"cover": cover, "cover": cover,
"is_explicit": isExplicit,
} }
return filtered return filtered
@@ -871,13 +884,23 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
trackID = parts[len(parts)-1] trackID = parts[len(parts)-1]
} }
contentRating := getMap(track, "contentRating")
isExplicit := getString(contentRating, "label") == "EXPLICIT"
discNumber := int(getFloat64(track, "discNumber"))
if discNumber == 0 {
discNumber = 1
}
trackInfo := map[string]interface{}{ trackInfo := map[string]interface{}{
"id": trackID, "id": trackID,
"name": getString(track, "name"), "name": getString(track, "name"),
"artists": trackArtistsString, "artists": trackArtistsString,
"artistIds": artistIDs, "artistIds": artistIDs,
"duration": durationString, "duration": durationString,
"plays": getString(track, "playcount"), "plays": getString(track, "playcount"),
"is_explicit": isExplicit,
"disc_number": discNumber,
} }
tracks = append(tracks, trackInfo) tracks = append(tracks, trackInfo)
} }
@@ -897,6 +920,12 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
albumID = parts[len(parts)-1] albumID = parts[len(parts)-1]
} }
totalDiscs := 1
discsData := getMap(albumData, "discs")
if len(discsData) > 0 {
totalDiscs = int(getFloat64(discsData, "totalCount"))
}
filtered := map[string]interface{}{ filtered := map[string]interface{}{
"id": albumID, "id": albumID,
"name": getString(albumData, "name"), "name": getString(albumData, "name"),
@@ -905,6 +934,10 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
"releaseDate": releaseDate, "releaseDate": releaseDate,
"count": len(tracks), "count": len(tracks),
"tracks": tracks, "tracks": tracks,
"discs": map[string]interface{}{
"totalCount": totalDiscs,
},
"label": getString(albumData, "label"),
} }
return filtered 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{}{ trackInfo := map[string]interface{}{
"id": trackID, "id": trackID,
"cover": trackCover, "cover": trackCover,
"title": getString(trackData, "name"), "title": trackName,
"artist": artistsString, "artist": artistsString,
"artistIds": artistIDs, "artistIds": artistIDs,
"plays": rank, "plays": rank,
@@ -1104,6 +1145,8 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
"albumArtist": albumArtistsString, "albumArtist": albumArtistsString,
"albumId": albumID, "albumId": albumID,
"duration": durationString, "duration": durationString,
"is_explicit": isExplicit,
"disc_number": int(getFloat64(trackData, "discNumber")),
} }
tracks = append(tracks, trackInfo) tracks = append(tracks, trackInfo)
} }
@@ -1197,12 +1240,20 @@ func extractRelease(release map[string]interface{}) map[string]interface{} {
year = yearVal year = yearVal
} }
var totalTracks int
tracksInfo := getMap(release, "tracks")
if tracksInfo != nil {
totalTracks = int(getFloat64(tracksInfo, "totalCount"))
}
return map[string]interface{}{ return map[string]interface{}{
"id": releaseID, "id": releaseID,
"name": getString(release, "name"), "name": getString(release, "name"),
"cover": cover, "cover": cover,
"date": releaseDate, "date": releaseDate,
"year": year, "year": year,
"total_tracks": totalTracks,
"type": getString(release, "type"),
} }
} }
@@ -1472,14 +1523,18 @@ func FilterSearch(data map[string]interface{}) map[string]interface{} {
albumName = getString(albumInfo, "name") albumName = getString(albumInfo, "name")
} }
contentRating := getMap(track, "contentRating")
isExplicit := getString(contentRating, "label") == "EXPLICIT"
trackResults := results["tracks"].([]map[string]interface{}) trackResults := results["tracks"].([]map[string]interface{})
trackResults = append(trackResults, map[string]interface{}{ trackResults = append(trackResults, map[string]interface{}{
"id": trackID, "id": trackID,
"name": trackName, "name": trackName,
"artists": trackArtistsString, "artists": trackArtistsString,
"album": albumName, "album": albumName,
"duration": durationString, "duration": durationString,
"cover": cover, "cover": cover,
"is_explicit": isExplicit,
}) })
results["tracks"] = trackResults results["tracks"] = trackResults
} }
+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"` DiscNumber int `json:"disc_number,omitempty"`
TotalDiscs int `json:"total_discs,omitempty"` TotalDiscs int `json:"total_discs,omitempty"`
ExternalURL string `json:"external_urls"` ExternalURL string `json:"external_urls"`
ISRC string `json:"isrc"`
Copyright string `json:"copyright,omitempty"` Copyright string `json:"copyright,omitempty"`
Publisher string `json:"publisher,omitempty"` Publisher string `json:"publisher,omitempty"`
Plays string `json:"plays,omitempty"` Plays string `json:"plays,omitempty"`
PreviewURL string `json:"preview_url,omitempty"` PreviewURL string `json:"preview_url,omitempty"`
IsExplicit bool `json:"is_explicit,omitempty"`
} }
type ArtistSimple struct { type ArtistSimple struct {
@@ -69,7 +69,6 @@ type AlbumTrackMetadata struct {
DiscNumber int `json:"disc_number,omitempty"` DiscNumber int `json:"disc_number,omitempty"`
TotalDiscs int `json:"total_discs,omitempty"` TotalDiscs int `json:"total_discs,omitempty"`
ExternalURL string `json:"external_urls"` ExternalURL string `json:"external_urls"`
ISRC string `json:"isrc"`
AlbumType string `json:"album_type,omitempty"` AlbumType string `json:"album_type,omitempty"`
AlbumID string `json:"album_id,omitempty"` AlbumID string `json:"album_id,omitempty"`
AlbumURL string `json:"album_url,omitempty"` AlbumURL string `json:"album_url,omitempty"`
@@ -79,6 +78,7 @@ type AlbumTrackMetadata struct {
Plays string `json:"plays,omitempty"` Plays string `json:"plays,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
PreviewURL string `json:"preview_url,omitempty"` PreviewURL string `json:"preview_url,omitempty"`
IsExplicit bool `json:"is_explicit,omitempty"`
} }
type TrackResponse struct { type TrackResponse struct {
@@ -198,6 +198,7 @@ type apiTrackResponse struct {
Medium string `json:"medium"` Medium string `json:"medium"`
Large string `json:"large"` Large string `json:"large"`
} `json:"cover"` } `json:"cover"`
IsExplicit bool `json:"is_explicit"`
} }
type apiAlbumResponse struct { type apiAlbumResponse struct {
@@ -207,13 +208,19 @@ type apiAlbumResponse struct {
Cover string `json:"cover"` Cover string `json:"cover"`
ReleaseDate string `json:"releaseDate"` ReleaseDate string `json:"releaseDate"`
Count int `json:"count"` Count int `json:"count"`
Tracks []struct { Label string `json:"label"`
ID string `json:"id"` Discs struct {
Name string `json:"name"` TotalCount int `json:"totalCount"`
Artists string `json:"artists"` } `json:"discs"`
ArtistIds []string `json:"artistIds"` Tracks []struct {
Duration string `json:"duration"` ID string `json:"id"`
Plays string `json:"plays"` 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"` } `json:"tracks"`
} }
@@ -240,6 +247,8 @@ type apiPlaylistResponse struct {
AlbumArtist string `json:"albumArtist"` AlbumArtist string `json:"albumArtist"`
AlbumID string `json:"albumId"` AlbumID string `json:"albumId"`
Duration string `json:"duration"` Duration string `json:"duration"`
IsExplicit bool `json:"is_explicit"`
DiscNumber int `json:"disc_number"`
} `json:"tracks"` } `json:"tracks"`
} }
@@ -261,11 +270,13 @@ type apiArtistResponse struct {
Gallery []string `json:"gallery"` Gallery []string `json:"gallery"`
Discography struct { Discography struct {
All []struct { All []struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Cover string `json:"cover"` Cover string `json:"cover"`
Date string `json:"date"` Date string `json:"date"`
Year int `json:"year"` Year int `json:"year"`
TotalTracks int `json:"total_tracks"`
Type string `json:"type"`
} `json:"all"` } `json:"all"`
Total int `json:"total"` Total int `json:"total"`
} `json:"discography"` } `json:"discography"`
@@ -274,12 +285,13 @@ type apiArtistResponse struct {
type apiSearchResponse struct { type apiSearchResponse struct {
Results struct { Results struct {
Tracks []struct { Tracks []struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Artists string `json:"artists"` Artists string `json:"artists"`
Album string `json:"album"` Album string `json:"album"`
Duration string `json:"duration"` Duration string `json:"duration"`
Cover string `json:"cover"` Cover string `json:"cover"`
IsExplicit bool `json:"is_explicit"`
} `json:"tracks"` } `json:"tracks"`
Albums []struct { Albums []struct {
ID string `json:"id"` ID string `json:"id"`
@@ -320,6 +332,7 @@ type SearchResult struct {
Duration int `json:"duration_ms,omitempty"` Duration int `json:"duration_ms,omitempty"`
TotalTracks int `json:"total_tracks,omitempty"` TotalTracks int `json:"total_tracks,omitempty"`
Owner string `json:"owner,omitempty"` Owner string `json:"owner,omitempty"`
IsExplicit bool `json:"is_explicit,omitempty"`
} }
type SearchResponse struct { type SearchResponse struct {
@@ -423,22 +436,47 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string)
} }
if albumID != "" { if albumID != "" {
albumPayload := map[string]interface{}{
"variables": map[string]interface{}{ albumResponse, err := c.fetchAlbumWithClient(ctx, client, albumID)
"uri": fmt.Sprintf("spotify:album:%s", albumID), if err == nil && albumResponse != nil {
"locale": "",
"offset": 0, albumJSON, _ := json.Marshal(albumResponse)
"limit": 1, var albumMap map[string]interface{}
}, json.Unmarshal(albumJSON, &albumMap)
"operationName": "getAlbum",
"extensions": map[string]interface{}{ tracksItems := []interface{}{}
"persistedQuery": map[string]interface{}{ if albumMap["tracks"] != nil {
"version": 1, if trackList, ok := albumMap["tracks"].([]interface{}); ok {
"sha256Hash": "b9bfabef66ed756e5e13f68a942deb60bd4125ec1f1be8cc42769dc0259b4b10", 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 { if err := client.Initialize(); err != nil {
return nil, fmt.Errorf("failed to initialize spotify client: %w", err) return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
} }
return c.fetchAlbumWithClient(ctx, client, albumID)
}
func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client *SpotifyClient, albumID string) (*apiAlbumResponse, error) {
allItems := []interface{}{} allItems := []interface{}{}
offset := 0 offset := 0
@@ -727,6 +769,12 @@ func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, pars
} }
offset += limit offset += limit
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
} }
albumsItems := []interface{}{} albumsItems := []interface{}{}
@@ -839,10 +887,10 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp
DiscNumber: raw.Disc, DiscNumber: raw.Disc,
TotalDiscs: raw.Discs, TotalDiscs: raw.Discs,
ExternalURL: externalURL, ExternalURL: externalURL,
ISRC: raw.ID,
Copyright: raw.Copyright, Copyright: raw.Copyright,
Publisher: raw.Album.Label, Publisher: raw.Album.Label,
Plays: raw.Plays, Plays: raw.Plays,
IsExplicit: raw.IsExplicit,
} }
return TrackResponse{ return TrackResponse{
@@ -894,16 +942,16 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumRe
ReleaseDate: raw.ReleaseDate, ReleaseDate: raw.ReleaseDate,
TrackNumber: trackNumber, TrackNumber: trackNumber,
TotalTracks: raw.Count, TotalTracks: raw.Count,
DiscNumber: 1, DiscNumber: item.DiscNumber,
TotalDiscs: 0, TotalDiscs: raw.Discs.TotalCount,
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID), ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
ISRC: item.ID,
AlbumID: raw.ID, AlbumID: raw.ID,
AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", raw.ID), AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", raw.ID),
ArtistID: artistID, ArtistID: artistID,
ArtistURL: artistURL, ArtistURL: artistURL,
ArtistsData: artistsData, ArtistsData: artistsData,
Plays: item.Plays, Plays: item.Plays,
IsExplicit: item.IsExplicit,
}) })
} }
@@ -953,10 +1001,9 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla
ReleaseDate: "", ReleaseDate: "",
TrackNumber: 0, TrackNumber: 0,
TotalTracks: 0, TotalTracks: 0,
DiscNumber: 1, DiscNumber: item.DiscNumber,
TotalDiscs: 0, TotalDiscs: 0,
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID), ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
ISRC: item.ID,
AlbumID: item.AlbumID, AlbumID: item.AlbumID,
AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", item.AlbumID), AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", item.AlbumID),
ArtistID: artistID, ArtistID: artistID,
@@ -964,6 +1011,7 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla
ArtistsData: artistsData, ArtistsData: artistsData,
Plays: item.Plays, Plays: item.Plays,
Status: item.Status, Status: item.Status,
IsExplicit: item.IsExplicit,
}) })
} }
@@ -995,79 +1043,104 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
albumList := make([]DiscographyAlbumMetadata, 0, len(raw.Discography.All)) albumList := make([]DiscographyAlbumMetadata, 0, len(raw.Discography.All))
allTracks := make([]AlbumTrackMetadata, 0) allTracks := make([]AlbumTrackMetadata, 0)
type fetchResult struct {
tracks []AlbumTrackMetadata
err error
}
resultsChan := make(chan fetchResult, len(raw.Discography.All))
sem := make(chan struct{}, 5)
sharedClient := NewSpotifyClient()
if err := sharedClient.Initialize(); err != nil {
return nil, fmt.Errorf("failed to initialize shared spotify client: %w", err)
}
for _, alb := range raw.Discography.All { for _, alb := range raw.Discography.All {
select {
case <-ctx.Done():
return &ArtistDiscographyPayload{
ArtistInfo: info,
AlbumList: albumList,
TrackList: allTracks,
}, ctx.Err()
default:
}
albumList = append(albumList, DiscographyAlbumMetadata{ albumList = append(albumList, DiscographyAlbumMetadata{
ID: alb.ID, ID: alb.ID,
Name: alb.Name, Name: alb.Name,
AlbumType: "album", AlbumType: alb.Type,
ReleaseDate: alb.Date, ReleaseDate: alb.Date,
TotalTracks: 0, TotalTracks: alb.TotalTracks,
Artists: raw.Name, Artists: raw.Name,
Images: alb.Cover, Images: alb.Cover,
ExternalURL: fmt.Sprintf("https://open.spotify.com/album/%s", alb.ID), ExternalURL: fmt.Sprintf("https://open.spotify.com/album/%s", alb.ID),
}) })
albumData, err := c.fetchAlbum(ctx, alb.ID) go func(albumID string, albumName string) {
if err != nil { sem <- struct{}{}
fmt.Printf("Error getting tracks for album %s: %v\n", alb.Name, err)
continue
}
for idx, tr := range albumData.Tracks { time.Sleep(100 * time.Millisecond)
durationMS := parseDuration(tr.Duration) defer func() { <-sem }()
trackNumber := idx + 1
var artistID, artistURL string select {
if len(tr.ArtistIds) > 0 { case <-ctx.Done():
artistID = tr.ArtistIds[0] resultsChan <- fetchResult{err: ctx.Err()}
artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", artistID) return
default:
} }
artistsData := make([]ArtistSimple, 0, len(tr.ArtistIds)) albumData, err := c.fetchAlbumWithClient(ctx, sharedClient, albumID)
for _, id := range tr.ArtistIds { if err != nil {
artistsData = append(artistsData, ArtistSimple{ fmt.Printf("Error getting tracks for album %s: %v\n", albumName, err)
ID: id, resultsChan <- fetchResult{tracks: []AlbumTrackMetadata{}}
Name: "", return
ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", id), }
tracks := make([]AlbumTrackMetadata, 0, len(albumData.Tracks))
for idx, tr := range albumData.Tracks {
durationMS := parseDuration(tr.Duration)
trackNumber := idx + 1
var artistID, artistURL string
if len(tr.ArtistIds) > 0 {
artistID = tr.ArtistIds[0]
artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", artistID)
}
artistsData := make([]ArtistSimple, 0, len(tr.ArtistIds))
for _, id := range tr.ArtistIds {
artistsData = append(artistsData, ArtistSimple{
ID: id,
Name: "",
ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", id),
})
}
tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: tr.ID,
Artists: tr.Artists,
Name: tr.Name,
AlbumName: albumData.Name,
AlbumArtist: raw.Name,
AlbumType: "album",
DurationMS: durationMS,
Images: albumData.Cover,
ReleaseDate: albumData.ReleaseDate,
TrackNumber: trackNumber,
TotalTracks: albumData.Count,
DiscNumber: 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{ for i := 0; i < len(raw.Discography.All); i++ {
SpotifyID: tr.ID, res := <-resultsChan
Artists: tr.Artists, if res.err != nil {
Name: tr.Name, return nil, res.err
AlbumName: albumData.Name,
AlbumArtist: albumData.Artists,
AlbumType: "album",
DurationMS: durationMS,
Images: albumData.Cover,
ReleaseDate: albumData.ReleaseDate,
TrackNumber: trackNumber,
TotalTracks: albumData.Count,
DiscNumber: 1,
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", tr.ID),
ISRC: tr.ID,
AlbumID: alb.ID,
AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", alb.ID),
ArtistID: artistID,
ArtistURL: artistURL,
ArtistsData: artistsData,
Plays: tr.Plays,
})
} }
allTracks = append(allTracks, res.tracks...)
} }
return &ArtistDiscographyPayload{ return &ArtistDiscographyPayload{
@@ -1246,6 +1319,7 @@ func (c *SpotifyMetadataClient) Search(ctx context.Context, query string, limit
Images: item.Cover, Images: item.Cover,
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID), ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
Duration: parseDuration(item.Duration), Duration: parseDuration(item.Duration),
IsExplicit: item.IsExplicit,
}) })
} }
@@ -1359,6 +1433,7 @@ func (c *SpotifyMetadataClient) SearchByType(ctx context.Context, query string,
Images: item.Cover, Images: item.Cover,
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID), ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
Duration: parseDuration(item.Duration), Duration: parseDuration(item.Duration),
IsExplicit: item.IsExplicit,
}) })
} }
case "album": case "album":
+335 -330
View File
@@ -6,6 +6,7 @@ import (
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"io" "io"
"math/rand"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -17,38 +18,10 @@ import (
) )
type TidalDownloader struct { type TidalDownloader struct {
client *http.Client client *http.Client
timeout time.Duration timeout time.Duration
maxRetries int maxRetries int
clientID string apiURL string
clientSecret string
apiURL string
}
type TidalTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
ISRC string `json:"isrc"`
AudioQuality string `json:"audioQuality"`
TrackNumber int `json:"trackNumber"`
VolumeNumber int `json:"volumeNumber"`
Duration int `json:"duration"`
Copyright string `json:"copyright"`
Explicit bool `json:"explicit"`
Album struct {
Title string `json:"title"`
Cover string `json:"cover"`
ReleaseDate string `json:"releaseDate"`
} `json:"album"`
Artists []struct {
Name string `json:"name"`
} `json:"artists"`
Artist struct {
Name string `json:"name"`
} `json:"artist"`
MediaMetadata struct {
Tags []string `json:"tags"`
} `json:"mediaMetadata"`
} }
type TidalAPIResponse struct { type TidalAPIResponse struct {
@@ -70,11 +43,6 @@ type TidalAPIResponseV2 struct {
} `json:"data"` } `json:"data"`
} }
type TidalAPIInfo struct {
URL string `json:"url"`
Status string `json:"status"`
}
type TidalBTSManifest struct { type TidalBTSManifest struct {
MimeType string `json:"mimeType"` MimeType string `json:"mimeType"`
Codecs string `json:"codecs"` Codecs string `json:"codecs"`
@@ -83,19 +51,14 @@ type TidalBTSManifest struct {
} }
func NewTidalDownloader(apiURL string) *TidalDownloader { func NewTidalDownloader(apiURL string) *TidalDownloader {
clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==")
clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=")
if apiURL == "" { if apiURL == "" {
downloader := &TidalDownloader{ downloader := &TidalDownloader{
client: &http.Client{ client: &http.Client{
Timeout: 5 * time.Second, Timeout: 5 * time.Second,
}, },
timeout: 5 * time.Second, timeout: 5 * time.Second,
maxRetries: 3, maxRetries: 3,
clientID: string(clientID), apiURL: "",
clientSecret: string(clientSecret),
apiURL: "",
} }
apis, err := downloader.GetAvailableAPIs() apis, err := downloader.GetAvailableAPIs()
@@ -108,85 +71,36 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
client: &http.Client{ client: &http.Client{
Timeout: 5 * time.Second, Timeout: 5 * time.Second,
}, },
timeout: 5 * time.Second, timeout: 5 * time.Second,
maxRetries: 3, maxRetries: 3,
clientID: string(clientID), apiURL: apiURL,
clientSecret: string(clientSecret),
apiURL: apiURL,
} }
} }
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) { func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
apis := []string{
encodedAPIs := []string{ "https://triton.squid.wtf",
"dm9nZWwucXFkbC5zaXRl", "https://hifi-one.spotisaver.net",
"bWF1cy5xcWRsLnNpdGU=", "https://hifi-two.spotisaver.net",
"aHVuZC5xcWRsLnNpdGU=",
"a2F0emUucXFkbC5zaXRl",
"d29sZi5xcWRsLnNpdGU=",
"dGlkYWwua2lub3BsdXMub25saW5l",
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn",
"dHJpdG9uLnNxdWlkLnd0Zg==",
} }
var apis []string
for _, encoded := range encodedAPIs {
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
continue
}
apis = append(apis, "https://"+string(decoded))
}
return apis, nil return apis, nil
} }
func (t *TidalDownloader) GetAccessToken() (string, error) {
data := fmt.Sprintf("client_id=%s&grant_type=client_credentials", t.clientID)
authURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hdXRoLnRpZGFsLmNvbS92MS9vYXV0aDIvdG9rZW4=")
req, err := http.NewRequest("POST", string(authURL), strings.NewReader(data))
if err != nil {
return "", err
}
req.SetBasicAuth(t.clientID, t.clientSecret)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := t.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("failed to get access token: HTTP %d", resp.StatusCode)
}
var result struct {
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
return result.AccessToken, nil
}
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) { func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") spotifyBase := "https://open.spotify.com/track/"
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) spotifyURL := fmt.Sprintf("%s%s", spotifyBase, spotifyTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") apiBase := "https://api.song.link/v1-alpha.1/links?url="
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL)) apiURL := fmt.Sprintf("%s%s", apiBase, url.QueryEscape(spotifyURL))
req, err := http.NewRequest("GET", apiURL, nil) req, err := http.NewRequest("GET", apiURL, nil)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to create request: %w", 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")
fmt.Println("Getting Tidal URL...") fmt.Println("Getting Tidal URL...")
resp, err := t.client.Do(req) resp, err := t.client.Do(req)
@@ -237,49 +151,21 @@ func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
return trackID, nil return trackID, nil
} }
func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
token, err := t.GetAccessToken()
if err != nil {
return nil, fmt.Errorf("failed to get access token: %w", err)
}
trackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3RyYWNrcy8=")
trackURL := fmt.Sprintf("%s%d?countryCode=US", string(trackBase), trackID)
req, err := http.NewRequest("GET", trackURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := t.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("failed to get track info: HTTP %d - %s", resp.StatusCode, string(body))
}
var trackInfo TidalTrack
if err := json.NewDecoder(resp.Body).Decode(&trackInfo); err != nil {
return nil, err
}
fmt.Printf("Found: %s (%s)\n", trackInfo.Title, trackInfo.AudioQuality)
return &trackInfo, nil
}
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) { func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
fmt.Println("Fetching URL...") fmt.Println("Fetching URL...")
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality) url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality)
fmt.Printf("Tidal API URL: %s\n", url) 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 { if err != nil {
fmt.Printf("✗ Tidal API request failed: %v\n", err) fmt.Printf("✗ Tidal API request failed: %v\n", err)
return "", fmt.Errorf("failed to get download URL: %w", 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") return "", fmt.Errorf("download URL not found in response")
} }
func (t *TidalDownloader) DownloadAlbumArt(albumID string) ([]byte, error) {
albumID = strings.ReplaceAll(albumID, "-", "/")
imageBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9yZXNvdXJjZXMudGlkYWwuY29tL2ltYWdlcy8=")
artURL := fmt.Sprintf("%s%s/1280x1280.jpg", string(imageBase), albumID)
resp, err := t.client.Get(artURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("failed to download album art: HTTP %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
func (t *TidalDownloader) DownloadFile(url, filepath string) error { func (t *TidalDownloader) DownloadFile(url, filepath string) error {
if strings.HasPrefix(url, "MANIFEST:") { if strings.HasPrefix(url, "MANIFEST:") {
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath) 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 { if err != nil {
return fmt.Errorf("failed to download file: %w", err) 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 { func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) error {
directURL, initURL, mediaURLs, err := parseManifest(manifestB64) directURL, initURL, mediaURLs, mimeType, err := parseManifest(manifestB64)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse manifest: %w", err) 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, 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...") fmt.Println("Downloading file...")
resp, err := client.Get(directURL) resp, err := doRequest(directURL)
if err != nil { if err != nil {
return fmt.Errorf("failed to download file: %w", err) return fmt.Errorf("failed to download file: %w", err)
} }
@@ -424,83 +307,116 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
return nil return nil
} }
fmt.Printf("Downloading %d segments...\n", len(mediaURLs)+1)
tempPath := outputPath + ".m4a.tmp" 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... ") if directURL != "" {
resp, err := client.Get(initURL) fmt.Printf("Downloading non-FLAC file (%s)...\n", mimeType)
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")
totalSegments := len(mediaURLs) resp, err := doRequest(directURL)
var totalBytes int64 if err != nil {
lastTime := time.Now() return fmt.Errorf("failed to download file: %w", err)
var lastBytes int64 }
for i, mediaURL := range mediaURLs { defer resp.Body.Close()
resp, err := client.Get(mediaURL)
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 { if err != nil {
out.Close() out.Close()
os.Remove(tempPath) 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 { if resp.StatusCode != 200 {
resp.Body.Close() resp.Body.Close()
out.Close() out.Close()
os.Remove(tempPath) 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) _, err = io.Copy(out, resp.Body)
totalBytes += n
resp.Body.Close() resp.Body.Close()
if err != nil { if err != nil {
out.Close() out.Close()
os.Remove(tempPath) 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) out.Close()
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) 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...") fmt.Println("Converting to FLAC...")
ffmpegPath, err := GetFFmpegPath() ffmpegPath, err := GetFFmpegPath()
if err != nil { if err != nil {
@@ -528,7 +444,7 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
return nil return nil
} }
func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) { func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
if outputDir != "." { if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil { if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("directory error: %w", err) return "", fmt.Errorf("directory error: %w", err)
@@ -542,12 +458,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
return "", err return "", err
} }
trackInfo, err := t.GetTrackInfoByID(trackID) if trackID == 0 {
if err != nil {
return "", err
}
if trackInfo.ID == 0 {
return "", fmt.Errorf("no track ID found") return "", fmt.Errorf("no track ID found")
} }
@@ -556,11 +467,17 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
albumTitle := spotifyAlbumName albumTitle := spotifyAlbumName
artistNameForFile := sanitizeFilename(artistName) artistNameForFile := sanitizeFilename(artistName)
trackTitleForFile := sanitizeFilename(trackTitle)
albumTitleForFile := sanitizeFilename(albumTitle)
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist) 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) outputFilename := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 {
@@ -568,9 +485,53 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
return "EXISTS:" + outputFilename, nil return "EXISTS:" + outputFilename, nil
} }
downloadURL, err := t.GetDownloadURL(trackInfo.ID, quality) downloadURL, err := t.GetDownloadURL(trackID, quality)
if err != nil { if err != nil {
return "", err if quality == "HI_RES" && allowFallback {
fmt.Println("⚠ HI_RES unavailable/failed, falling back to LOSSLESS...")
downloadURL, err = t.GetDownloadURL(trackID, "LOSSLESS")
if err != nil {
return "", fmt.Errorf("failed to get download URL (HI_RES & LOSSLESS both failed): %w", err)
}
} else {
return "", err
}
}
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) fmt.Printf("Downloading to: %s\n", outputFilename)
@@ -578,6 +539,14 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
return "", err return "", err
} }
var isrc string
var mbMeta Metadata
if spotifyURL != "" {
result := <-metaChan
isrc = result.ISRC
mbMeta = result.Metadata
}
fmt.Println("Adding metadata...") fmt.Println("Adding metadata...")
coverPath := "" coverPath := ""
@@ -613,6 +582,8 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
Copyright: spotifyCopyright, Copyright: spotifyCopyright,
Publisher: spotifyPublisher, Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC", Description: "https://github.com/afkarxyz/SpotiFLAC",
ISRC: isrc,
Genre: mbMeta.Genre,
} }
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil { if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
@@ -626,7 +597,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
return outputFilename, nil return outputFilename, nil
} }
func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) { func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
apis, err := t.GetAvailableAPIs() apis, err := t.GetAvailableAPIs()
if err != nil { if err != nil {
return "", fmt.Errorf("no APIs available for fallback: %w", err) return "", fmt.Errorf("no APIs available for fallback: %w", err)
@@ -645,12 +616,7 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
return "", err return "", err
} }
trackInfo, err := t.GetTrackInfoByID(trackID) if trackID == 0 {
if err != nil {
return "", err
}
if trackInfo.ID == 0 {
return "", fmt.Errorf("no track ID found") return "", fmt.Errorf("no track ID found")
} }
@@ -659,11 +625,17 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
albumTitle := spotifyAlbumName albumTitle := spotifyAlbumName
artistNameForFile := sanitizeFilename(artistName) artistNameForFile := sanitizeFilename(artistName)
trackTitleForFile := sanitizeFilename(trackTitle)
albumTitleForFile := sanitizeFilename(albumTitle)
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist) 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) outputFilename := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 {
@@ -671,9 +643,53 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
return "EXISTS:" + outputFilename, nil return "EXISTS:" + outputFilename, nil
} }
successAPI, downloadURL, err := getDownloadURLParallel(apis, trackInfo.ID, quality) successAPI, downloadURL, err := getDownloadURLRotated(apis, trackID, quality)
if err != nil { if err != nil {
return "", err if quality == "HI_RES" && allowFallback {
fmt.Println("⚠ HI_RES unavailable/failed on all APIs, falling back to LOSSLESS...")
successAPI, downloadURL, err = getDownloadURLRotated(apis, trackID, "LOSSLESS")
if err != nil {
return "", fmt.Errorf("failed to get download URL (HI_RES & LOSSLESS both failed): %w", err)
}
} else {
return "", err
}
}
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) fmt.Printf("Downloading to: %s\n", outputFilename)
@@ -682,6 +698,14 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
return "", err return "", err
} }
var isrc string
var mbMeta Metadata
if spotifyURL != "" {
result := <-metaChan
isrc = result.ISRC
mbMeta = result.Metadata
}
fmt.Println("Adding metadata...") fmt.Println("Adding metadata...")
coverPath := "" coverPath := ""
@@ -717,6 +741,8 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
Copyright: spotifyCopyright, Copyright: spotifyCopyright,
Publisher: spotifyPublisher, Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC", Description: "https://github.com/afkarxyz/SpotiFLAC",
ISRC: isrc,
Genre: mbMeta.Genre,
} }
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil { if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
@@ -730,14 +756,14 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
return outputFilename, nil return outputFilename, nil
} }
func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) { func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID) tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
if err != nil { if err != nil {
return "", fmt.Errorf("songlink couldn't find Tidal URL: %w", err) return "", fmt.Errorf("songlink couldn't find Tidal URL: %w", err)
} }
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL) return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
} }
type SegmentTemplate struct { type SegmentTemplate struct {
@@ -768,10 +794,10 @@ type MPD struct {
} `xml:"Period"` } `xml:"Period"`
} }
func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) { func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, mimeType string, err error) {
manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64) manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64)
if err != nil { if err != nil {
return "", "", nil, fmt.Errorf("failed to decode manifest: %w", err) return "", "", nil, "", fmt.Errorf("failed to decode manifest: %w", err)
} }
manifestStr := string(manifestBytes) manifestStr := string(manifestBytes)
@@ -779,15 +805,15 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
if strings.HasPrefix(strings.TrimSpace(manifestStr), "{") { if strings.HasPrefix(strings.TrimSpace(manifestStr), "{") {
var btsManifest TidalBTSManifest var btsManifest TidalBTSManifest
if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil { if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil {
return "", "", nil, fmt.Errorf("failed to parse BTS manifest: %w", err) return "", "", nil, "", fmt.Errorf("failed to parse BTS manifest: %w", err)
} }
if len(btsManifest.URLs) == 0 { 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) 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") 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)) mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
mediaURLs = append(mediaURLs, mediaURL) mediaURLs = append(mediaURLs, mediaURL)
} }
return "", initURL, mediaURLs, nil return "", initURL, mediaURLs, "", nil
} }
fmt.Println("Using regex fallback for DASH manifest...") fmt.Println("Using regex fallback for DASH manifest...")
@@ -868,7 +894,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
} }
if initURL == "" { 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;", "&") initURL = strings.ReplaceAll(initURL, "&amp;", "&")
@@ -889,7 +915,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
} }
if segmentCount == 0 { 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) 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) mediaURLs = append(mediaURLs, mediaURL)
} }
return "", initURL, mediaURLs, nil return "", initURL, mediaURLs, "", nil
} }
type manifestResult struct { func getDownloadURLRotated(apis []string, trackID int64, quality string) (string, string, error) {
apiURL string
manifest string
err error
}
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) {
if len(apis) == 0 { if len(apis) == 0 {
return "", "", fmt.Errorf("no APIs available") return "", "", fmt.Errorf("no APIs available")
} }
resultChan := make(chan manifestResult, len(apis)) rand.Seed(time.Now().UnixNano())
rand.Shuffle(len(apis), func(i, j int) { apis[i], apis[j] = apis[j], apis[i] })
fmt.Printf("Requesting download URL from %d APIs in parallel...\n", len(apis)) fmt.Printf("Rotating through %d APIs...\n", len(apis))
for _, apiURL := range apis {
go func(api string) {
client := &http.Client{
Timeout: 15 * time.Second,
}
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality)
resp, err := client.Get(url)
if err != nil {
resultChan <- manifestResult{apiURL: api, err: err}
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
resultChan <- manifestResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode)}
return
}
body, err := io.ReadAll(resp.Body)
if err != nil {
resultChan <- manifestResult{apiURL: api, err: err}
return
}
var v2Response TidalAPIResponseV2
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
resultChan <- manifestResult{apiURL: api, manifest: v2Response.Data.Manifest, err: nil}
return
}
var v1Responses []TidalAPIResponse
if err := json.Unmarshal(body, &v1Responses); err == nil {
for _, item := range v1Responses {
if item.OriginalTrackURL != "" {
resultChan <- manifestResult{apiURL: api, manifest: "DIRECT:" + item.OriginalTrackURL, err: nil}
return
}
}
}
resultChan <- manifestResult{apiURL: api, err: fmt.Errorf("no download URL or manifest in response")}
}(apiURL)
}
var lastError error var lastError error
var errors []string var errors []string
for i := 0; i < len(apis); i++ { for _, apiURL := range apis {
result := <-resultChan fmt.Printf("Trying API: %s\n", apiURL)
if result.err == nil && result.manifest != "" {
fmt.Printf("✓ Got response from: %s\n", result.apiURL) client := &http.Client{
Timeout: 15 * time.Second,
if strings.HasPrefix(result.manifest, "DIRECT:") {
return result.apiURL, strings.TrimPrefix(result.manifest, "DIRECT:"), nil
}
return result.apiURL, "MANIFEST:" + result.manifest, nil
} else {
errMsg := result.err.Error()
if len(errMsg) > 50 {
errMsg = errMsg[:50] + "..."
}
errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg))
lastError = result.err
} }
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", apiURL, trackID, quality)
resp, err := client.Get(url)
if err != nil {
lastError = err
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err))
continue
}
if resp.StatusCode != 200 {
resp.Body.Close()
lastError = fmt.Errorf("HTTP %d", resp.StatusCode)
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, lastError))
continue
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastError = err
errors = append(errors, fmt.Sprintf("%s: read body failed", apiURL))
continue
}
var v2Response TidalAPIResponseV2
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
fmt.Printf("✓ Success with: %s\n", apiURL)
return apiURL, "MANIFEST:" + v2Response.Data.Manifest, nil
}
var v1Responses []TidalAPIResponse
if err := json.Unmarshal(body, &v1Responses); err == nil {
for _, item := range v1Responses {
if item.OriginalTrackURL != "" {
fmt.Printf("✓ Success with: %s\n", apiURL)
return apiURL, item.OriginalTrackURL, nil
}
}
}
lastError = fmt.Errorf("no download URL or manifest in response")
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, lastError))
} }
fmt.Println("All APIs failed:") fmt.Println("All APIs failed:")
@@ -1015,6 +1019,7 @@ func buildTidalFilename(title, artist, album, albumArtist, releaseDate string, t
filename = strings.ReplaceAll(filename, "{album}", album) filename = strings.ReplaceAll(filename, "{album}", album)
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist) filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
filename = strings.ReplaceAll(filename, "{year}", year) filename = strings.ReplaceAll(filename, "{year}", year)
filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate))
if discNumber > 0 { if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) 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-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
@@ -24,32 +26,32 @@
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.2.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.575.0",
"motion": "^12.26.2", "motion": "^12.34.3",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.3", "react": "^19.2.4",
"react-dom": "^19.2.3", "react-dom": "^19.2.4",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.1.18" "tailwindcss": "^4.2.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2", "@eslint/js": "^10.0.1",
"@types/node": "^25.0.8", "@types/node": "^25.3.0",
"@types/react": "^19.2.8", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.4",
"eslint": "^9.39.2", "eslint": "^10.0.2",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.0.0", "globals": "^17.3.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.53.0", "typescript-eslint": "^8.56.1",
"vite": "^7.3.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 { useState, useEffect, useCallback, useLayoutEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Search, X, ArrowUp } from "lucide-react"; import { Search, X, ArrowUp } from "lucide-react";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont } from "@/lib/settings"; import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont, updateSettings } from "@/lib/settings";
import { applyTheme } from "@/lib/themes"; import { applyTheme } from "@/lib/themes";
import { OpenFolder, CheckFFmpegInstalled, DownloadFFmpeg } from "../wailsjs/go/main/App"; import { OpenFolder, CheckFFmpegInstalled, DownloadFFmpeg } from "../wailsjs/go/main/App";
import { EventsOn, EventsOff, Quit } from "../wailsjs/runtime/runtime"; import { EventsOn, EventsOff, Quit } from "../wailsjs/runtime/runtime";
@@ -48,14 +46,18 @@ function App() {
const [releaseDate, setReleaseDate] = useState<string | null>(null); const [releaseDate, setReleaseDate] = useState<string | null>(null);
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]); const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
const [isSearchMode, setIsSearchMode] = useState(false); const [isSearchMode, setIsSearchMode] = useState(false);
const [region, setRegion] = useState(() => localStorage.getItem("spotiflac_region") || "US");
useEffect(() => {
localStorage.setItem("spotiflac_region", region);
}, [region]);
const [showScrollTop, setShowScrollTop] = useState(false); const [showScrollTop, setShowScrollTop] = useState(false);
const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false); const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false);
const [pendingPageChange, setPendingPageChange] = useState<PageType | null>(null); const [pendingPageChange, setPendingPageChange] = useState<PageType | null>(null);
const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false); const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false);
const [resetSettingsFn, setResetSettingsFn] = useState<(() => void) | null>(null); const [resetSettingsFn, setResetSettingsFn] = useState<(() => void) | null>(null);
const ITEMS_PER_PAGE = 50; const ITEMS_PER_PAGE = 50;
const CURRENT_VERSION = "7.0.6"; const CURRENT_VERSION = __APP_VERSION__;
const download = useDownload(); const download = useDownload(region);
const metadata = useMetadata(); const metadata = useMetadata();
const lyrics = useLyrics(); const lyrics = useLyrics();
const cover = useCover(); const cover = useCover();
@@ -117,6 +119,17 @@ function App() {
window.removeEventListener("scroll", handleScroll); 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(() => { const scrollToTop = useCallback(() => {
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top: 0, behavior: "smooth" });
}, []); }, []);
@@ -288,16 +301,19 @@ function App() {
setSearchQuery(value); setSearchQuery(value);
setCurrentListPage(1); setCurrentListPage(1);
}; };
const toggleTrackSelection = (isrc: string) => { const toggleTrackSelection = (id: string) => {
setSelectedTracks((prev) => prev.includes(isrc) ? prev.filter((id) => id !== isrc) : [...prev, isrc]); setSelectedTracks((prev) => prev.includes(id) ? prev.filter((prevId) => prevId !== id) : [...prev, id]);
}; };
const toggleSelectAll = (tracks: any[]) => { const toggleSelectAll = (tracks: any[]) => {
const tracksWithIsrc = tracks.filter((track) => track.isrc).map((track) => track.isrc); const tracksWithId = tracks.filter((track) => track.spotify_id).map((track) => track.spotify_id || "");
if (selectedTracks.length === tracksWithIsrc.length) { if (tracksWithId.length === 0)
setSelectedTracks([]); return;
const allSelected = tracksWithId.every(id => selectedTracks.includes(id));
if (allSelected) {
setSelectedTracks(prev => prev.filter(id => !tracksWithId.includes(id)));
} }
else { else {
setSelectedTracks(tracksWithIsrc); setSelectedTracks(prev => Array.from(new Set([...prev, ...tracksWithId])));
} }
}; };
const handleOpenFolder = async () => { const handleOpenFolder = async () => {
@@ -319,11 +335,12 @@ function App() {
return null; return null;
if ("track" in metadata.metadata) { if ("track" in metadata.metadata) {
const { track } = metadata.metadata; const { track } = metadata.metadata;
return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(track.isrc)} isFailed={download.failedTracks.has(track.isrc)} isSkipped={download.skippedTracks.has(track.isrc)} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} checkingAvailability={availability.checkingTrackId === track.spotify_id} availability={availability.availabilityMap.get(track.spotify_id || "")} downloadingCover={cover.downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)} downloadedCover={cover.downloadedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} failedCover={cover.failedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} skippedCover={cover.skippedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} onDownload={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, track.album_name, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, track.album_name, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder}/>); 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) { if ("album_info" in metadata.metadata) {
const { album_info, track_list } = metadata.metadata; const { album_info, track_list } = metadata.metadata;
return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onArtistClick={async (artist) => { return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
const artistUrl = await metadata.handleArtistClick(artist); const artistUrl = await metadata.handleArtistClick(artist);
if (artistUrl) { if (artistUrl) {
setSpotifyUrl(artistUrl); setSpotifyUrl(artistUrl);
@@ -337,7 +354,7 @@ function App() {
} }
if ("playlist_info" in metadata.metadata) { if ("playlist_info" in metadata.metadata) {
const { playlist_info, track_list } = metadata.metadata; const { playlist_info, track_list } = metadata.metadata;
return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => { return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.owner.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlist_info.owner.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
const artistUrl = await metadata.handleArtistClick(artist); const artistUrl = await metadata.handleArtistClick(artist);
if (artistUrl) { if (artistUrl) {
setSpotifyUrl(artistUrl); setSpotifyUrl(artistUrl);
@@ -351,7 +368,7 @@ function App() {
} }
if ("artist_info" in metadata.metadata) { if ("artist_info" in metadata.metadata) {
const { artist_info, album_list, track_list } = metadata.metadata; const { artist_info, album_list, track_list } = metadata.metadata;
return (<ArtistInfo artistInfo={artist_info} albumList={album_list} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => { return (<ArtistInfo artistInfo={artist_info} albumList={album_list} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
const artistUrl = await metadata.handleArtistClick(artist); const artistUrl = await metadata.handleArtistClick(artist);
if (artistUrl) { if (artistUrl) {
setSpotifyUrl(artistUrl); setSpotifyUrl(artistUrl);
@@ -400,7 +417,10 @@ function App() {
case "about": case "about":
return <AboutPage version={CURRENT_VERSION}/>; return <AboutPage version={CURRENT_VERSION}/>;
case "history": case "history":
return <HistoryPage />; return <HistoryPage onHistorySelect={(cachedData) => {
metadata.loadFromCache(cachedData);
setCurrentPage("main");
}}/>;
case "audio-analysis": case "audio-analysis":
return <AudioAnalysisPage />; return <AudioAnalysisPage />;
case "audio-converter": case "audio-converter":
@@ -412,42 +432,6 @@ function App() {
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} releaseDate={releaseDate}/> <Header version={CURRENT_VERSION} hasUpdate={hasUpdate} releaseDate={releaseDate}/>
<Dialog open={metadata.showTimeoutDialog} onOpenChange={metadata.setShowTimeoutDialog}>
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
<div className="absolute right-4 top-4">
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowTimeoutDialog(false)}>
<X className="h-4 w-4"/>
</Button>
</div>
<DialogTitle className="text-sm font-medium">Fetch Artist</DialogTitle>
<DialogDescription>
Set timeout for fetching metadata. Longer timeout is recommended for artists
with large discography.
</DialogDescription>
{metadata.pendingArtistName && (<div className="py-2">
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.pendingArtistName}</p>
</div>)}
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="timeout">Timeout (seconds)</Label>
<Input id="timeout" type="number" min="10" max="600" value={metadata.timeoutValue} onChange={(e) => metadata.setTimeoutValue(Number(e.target.value))}/>
<p className="text-xs text-muted-foreground">
Default: 60 seconds. For large discographies, try 300-600 seconds (5-10
minutes).
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => metadata.setShowTimeoutDialog(false)}>
Cancel
</Button>
<Button onClick={metadata.handleConfirmFetch}>
<Search className="h-4 w-4"/>
Fetch
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}> <Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
@@ -487,7 +471,7 @@ function App() {
if (updatedUrl) { if (updatedUrl) {
setSpotifyUrl(updatedUrl); setSpotifyUrl(updatedUrl);
} }
}} history={fetchHistory} onHistorySelect={handleHistorySelect} onHistoryRemove={removeFromHistory} hasResult={!!metadata.metadata} searchMode={isSearchMode} onSearchModeChange={setIsSearchMode}/> }} history={fetchHistory} onHistorySelect={handleHistorySelect} onHistoryRemove={removeFromHistory} hasResult={!!metadata.metadata} searchMode={isSearchMode} onSearchModeChange={setIsSearchMode} region={region} onRegionChange={setRegion}/>
{!isSearchMode && metadata.metadata && renderMetadata()} {!isSearchMode && metadata.metadata && renderMetadata()}
</>); </>);
@@ -583,6 +567,25 @@ function App() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </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> </div>
</TooltipProvider>); </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 { Button } from "@/components/ui/button";
import { openExternal } from "@/lib/utils"; import { openExternal } from "@/lib/utils";
import { GetOSInfo } from "../../wailsjs/go/main/App"; import { GetOSInfo } from "../../wailsjs/go/main/App";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Bug, Lightbulb, ExternalLink, Star, GitFork, Clock, Download } from "lucide-react"; import { Bug, Lightbulb, ExternalLink, Star, GitFork, Clock, Download, CircleHelp, Blocks, Heart, } from "lucide-react";
import AudioTTSProIcon from "@/assets/audiotts-pro.webp"; import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp"; import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
import XProIcon from "@/assets/x-pro.webp"; import XProIcon from "@/assets/x-pro.webp";
import SpotubeDLIcon from "@/assets/icons/spotubedl.svg"; import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg"; import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg";
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg"; import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
import SpotiFLACNextIcon from "@/assets/icons/next.svg";
import KofiLogo from "@/assets/kofi_symbol.svg";
import { langColors } from "@/assets/github-lang-colors"; import { langColors } from "@/assets/github-lang-colors";
import { ScrollArea } from "@/components/ui/scroll-area";
import { DragDropMedia } from "./DragDropTextarea";
interface AboutPageProps { interface AboutPageProps {
version: string; version: string;
} }
export function AboutPage({ version }: AboutPageProps) { export function AboutPage({ version }: AboutPageProps) {
const [os, setOs] = useState("Unknown"); const [os, setOs] = useState("Unknown");
const [location, setLocation] = useState("Unknown"); const [location, setLocation] = useState("Unknown");
const [reportType, setReportType] = useState("bug"); const [activeTab, setActiveTab] = useState<"bug_report" | "feature_request" | "faq" | "projects" | "support">("bug_report");
const [bugType, setBugType] = useState("Track");
const [problem, setProblem] = useState(""); const [problem, setProblem] = useState("");
const [bugType, setBugType] = useState<string>("Track");
const [spotifyUrl, setSpotifyUrl] = useState(""); const [spotifyUrl, setSpotifyUrl] = useState("");
const [bugContext, setBugContext] = useState(""); const [bugContext, setBugContext] = useState("");
const [featureDesc, setFeatureDesc] = useState(""); const [featureDesc, setFeatureDesc] = useState("");
@@ -50,14 +53,14 @@ export function AboutPage({ version }: AboutPageProps) {
fetchOS(); fetchOS();
const fetchLocation = async () => { const fetchLocation = async () => {
try { try {
const response = await fetch('https://ipapi.co/json/'); const response = await fetch("https://ipapi.co/json/");
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
const city = data.city || ''; const city = data.city || "";
const region = data.region || ''; const region = data.region || "";
const country = data.country_name || ''; const country = data.country_name || "";
const parts = [city, region, country].filter(Boolean); const parts = [city, region, country].filter(Boolean);
setLocation(parts.join(', ') || 'Unknown'); setLocation(parts.join(", ") || "Unknown");
} }
else { else {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -71,7 +74,7 @@ export function AboutPage({ version }: AboutPageProps) {
}; };
fetchLocation(); fetchLocation();
const fetchRepoStats = async () => { const fetchRepoStats = async () => {
const CACHE_KEY = 'github_repo_stats'; const CACHE_KEY = "github_repo_stats";
const CACHE_DURATION = 1000 * 60 * 60; const CACHE_DURATION = 1000 * 60 * 60;
const cached = localStorage.getItem(CACHE_KEY); const cached = localStorage.getItem(CACHE_KEY);
if (cached) { if (cached) {
@@ -83,12 +86,13 @@ export function AboutPage({ version }: AboutPageProps) {
} }
} }
catch (err) { catch (err) {
console.error('Failed to parse cache:', err); console.error("Failed to parse cache:", err);
} }
} }
const repos = [ const repos = [
{ name: 'SpotiDownloader', owner: 'afkarxyz' }, { name: "SpotiDownloader", owner: "afkarxyz" },
{ name: 'Twitter-X-Media-Batch-Downloader', owner: 'afkarxyz' } { name: "SpotiFLAC-Next", owner: "spotiverse" },
{ name: "Twitter-X-Media-Batch-Downloader", owner: "afkarxyz" },
]; ];
const stats: Record<string, any> = {}; const stats: Record<string, any> = {};
for (const repo of repos) { for (const repo of repos) {
@@ -96,7 +100,7 @@ export function AboutPage({ version }: AboutPageProps) {
const [repoRes, releasesRes, langsRes] = await Promise.all([ 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}`),
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/releases`), 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 (repoRes.status === 403) {
if (cached) { if (cached) {
@@ -112,9 +116,11 @@ export function AboutPage({ version }: AboutPageProps) {
let totalDownloads = 0; let totalDownloads = 0;
let latestDownloads = 0; let latestDownloads = 0;
if (releases.length > 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) => { 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); }, 0);
} }
const topLangs = Object.entries(languages) const topLangs = Object.entries(languages)
@@ -127,7 +133,7 @@ export function AboutPage({ version }: AboutPageProps) {
createdAt: repoData.created_at, createdAt: repoData.created_at,
totalDownloads, totalDownloads,
latestDownloads, latestDownloads,
languages: topLangs languages: topLangs,
}; };
} }
} }
@@ -148,28 +154,25 @@ export function AboutPage({ version }: AboutPageProps) {
const faqs = [ const faqs = [
{ {
q: "Is this software free?", 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?", 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?", 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?", 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?", 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 formatTimeAgo = (dateString: string): string => {
const now = new Date(); const now = new Date();
const updated = new Date(dateString); const updated = new Date(dateString);
@@ -177,13 +180,13 @@ export function AboutPage({ version }: AboutPageProps) {
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const diffMonths = Math.floor(diffDays / 30); const diffMonths = Math.floor(diffDays / 30);
if (diffDays === 0) if (diffDays === 0)
return 'today'; return "today";
if (diffDays === 1) if (diffDays === 1)
return '1d'; return "1d";
if (diffDays < 30) if (diffDays < 30)
return `${diffDays}d`; return `${diffDays}d`;
if (diffMonths === 1) if (diffMonths === 1)
return '1mo'; return "1mo";
if (diffMonths < 12) if (diffMonths < 12)
return `${diffMonths}mo`; return `${diffMonths}mo`;
const diffYears = Math.floor(diffMonths / 12); const diffYears = Math.floor(diffMonths / 12);
@@ -196,213 +199,367 @@ export function AboutPage({ version }: AboutPageProps) {
return num.toString(); return num.toString();
}; };
const getLangColor = (lang: string): string => { const getLangColor = (lang: string): string => {
return langColors[lang] || '#858585'; return langColors[lang] || "#858585";
}; };
const handleSubmit = () => { const handleSubmit = () => {
let title = ""; const title = activeTab === "bug_report"
let body = ""; ? `[Bug Report] ${problem.substring(0, 50)}${problem.length > 50 ? "..." : ""}`
if (reportType === "bug") { : `[Feature Request] ${featureDesc.substring(0, 50)}${featureDesc.length > 50 ? "..." : ""}`;
title = `[Bug Report] ${problem ? problem.substring(0, 50) + (problem.length > 50 ? "..." : "") : "Issue"}`; let bodyContent = "";
body = `### [Bug Report] if (activeTab === "bug_report") {
const contextContent = bugContext.trim()
? bugContext.trim()
: "Type here or send screenshot/recording";
bodyContent = `### [Bug Report]
#### Problem #### Problem
> ${problem || "Type here"} ${problem || "Type here"}
#### Type #### Type
${bugType || "Track / Album / Playlist / Artist"} ${bugType}
#### Spotify URL #### Spotify URL
> ${spotifyUrl || "Type here"} ${spotifyUrl || "Type here"}
#### Additional Context #### Additional Context
> ${bugContext || "Type here or send screenshot/recording"} ${contextContent}
#### Version #### Environment
SpotiFLAC v${version} - SpotiFLAC Version: ${version}
- OS: ${os}
#### OS - Location: ${location}`;
${sanitizeForURL(os || "Unknown")}
#### Location
${location || "Unknown"}
`;
} }
else { else {
title = `[Feature Request] ${featureDesc ? featureDesc.substring(0, 50) + (featureDesc.length > 50 ? "..." : "") : "Request"}`; const contextContent = featureContext.trim()
body = `### [Feature Request] ? featureContext.trim()
: "Type here or send screenshot/recording";
bodyContent = `### [Feature Request]
#### Description #### Description
> ${featureDesc || "Type here"} ${featureDesc || "Type here"}
#### Use Case #### Use Case
> ${useCase || "Type here"} ${useCase || "Type here"}
#### Additional Context #### Additional Context
> ${featureContext || "Type here or send screenshot/recording"} ${contextContent}`;
`;
} }
const url = `https://github.com/afkarxyz/SpotiFLAC/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`; const params = new URLSearchParams({
title: title,
body: bodyContent,
});
const url = `https://github.com/afkarxyz/SpotiFLAC/issues/new?${params.toString()}`;
openExternal(url); openExternal(url);
}; };
return (<div className="animate-in slide-in-from-bottom-12 fade-in duration-500 ease-out space-y-6"> return (<div className={`flex flex-col space-y-4 ${activeTab === "faq" ? "h-[calc(100vh-10rem)]" : ""}`}>
<div> <div className="flex items-center justify-between shrink-0">
<h2 className="text-2xl font-bold tracking-tight">About</h2> <h2 className="text-2xl font-bold tracking-tight">About</h2>
</div> </div>
<Tabs defaultValue="report" className="w-full"> <div className="flex gap-2 border-b shrink-0">
<TabsList className="grid w-full grid-cols-3 cursor-pointer"> <Button variant={activeTab === "bug_report" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("bug_report")} className="rounded-b-none">
<TabsTrigger value="report" className="cursor-pointer transition-colors hover:text-primary data-[state=active]:text-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none">Report Issue</TabsTrigger> <Bug className="h-4 w-4"/>
<TabsTrigger value="faq" className="cursor-pointer transition-colors hover:text-primary data-[state=active]:text-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none">FAQ</TabsTrigger> Bug Report
<TabsTrigger value="projects" className="cursor-pointer transition-colors hover:text-primary data-[state=active]:text-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none">Other Projects</TabsTrigger> </Button>
</TabsList> <Button variant={activeTab === "feature_request" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("feature_request")} className="rounded-b-none">
<Lightbulb className="h-4 w-4"/>
Feature Request
</Button>
<Button variant={activeTab === "faq" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("faq")} className="rounded-b-none">
<CircleHelp className="h-4 w-4"/>
FAQ
</Button>
<Button variant={activeTab === "projects" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("projects")} className="rounded-b-none">
<Blocks className="h-4 w-4"/>
Other Projects
</Button>
<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"> <div className={`flex-1 min-h-0 ${activeTab === "faq" ? "overflow-hidden" : ""}`}>
<Card> {activeTab === "bug_report" && (<div className="flex flex-col">
<CardContent className="space-y-4 pt-4"> <div className="space-y-4 pt-4 flex flex-col">
<Tabs value={reportType} onValueChange={setReportType} className="w-full"> <div className="mt-4 pr-2">
<TabsList className="w-full grid grid-cols-2 cursor-pointer pb-2"> <div className="grid md:grid-cols-3 gap-6">
<TabsTrigger value="bug" className="flex items-center gap-2 cursor-pointer transition-colors hover:text-primary data-[state=active]:text-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"><Bug className="h-4 w-4" /> Bug Report</TabsTrigger> <div className="space-y-2 flex flex-col">
<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> <Label>Problem</Label>
</TabsList> <Textarea className="h-56 resize-none" placeholder="Describe the problem..." value={problem} onChange={(e) => setProblem(e.target.value)}/>
</div>
<div className="mt-4"> <div className="space-y-2 flex flex-col">
{reportType === "bug" ? (<div className="grid md:grid-cols-2 gap-6"> <Label>Additional Context</Label>
<div className="space-y-2 flex flex-col"> <DragDropMedia className="min-h-[14rem]" value={bugContext} onChange={setBugContext}/>
<Label>Problem</Label> </div>
<Textarea className="flex-1 resize-none" placeholder="Describe the problem..." value={problem} onChange={e => setProblem(e.target.value)} /> <div className="space-y-4 flex flex-col">
</div> <div className="space-y-2">
<div className="space-y-4"> <Label>Type</Label>
<div className="space-y-2"> <ToggleGroup type="single" value={bugType} onValueChange={(val) => {
<Label>Type</Label> if (val)
<ToggleGroup type="single" value={bugType} onValueChange={(val) => { setBugType(val);
if (val) }} className="justify-start w-full cursor-pointer">
setBugType(val); <ToggleGroupItem value="Track" className="flex-1 cursor-pointer" aria-label="Toggle track">
}} className="justify-start w-full cursor-pointer"> Track
<ToggleGroupItem value="Track" className="flex-1 cursor-pointer" aria-label="Toggle track"> </ToggleGroupItem>
Track <ToggleGroupItem value="Album" className="flex-1 cursor-pointer" aria-label="Toggle album">
</ToggleGroupItem> Album
<ToggleGroupItem value="Album" className="flex-1 cursor-pointer" aria-label="Toggle album"> </ToggleGroupItem>
Album <ToggleGroupItem value="Playlist" className="flex-1 cursor-pointer" aria-label="Toggle playlist">
</ToggleGroupItem> Playlist
<ToggleGroupItem value="Playlist" className="flex-1 cursor-pointer" aria-label="Toggle playlist"> </ToggleGroupItem>
Playlist <ToggleGroupItem value="Artist" className="flex-1 cursor-pointer" aria-label="Toggle artist">
</ToggleGroupItem> Artist
<ToggleGroupItem value="Artist" className="flex-1 cursor-pointer" aria-label="Toggle artist"> </ToggleGroupItem>
Artist </ToggleGroup>
</ToggleGroupItem> </div>
</ToggleGroup> <div className="space-y-2">
</div> <Label>Spotify URL</Label>
<div className="space-y-2"> <Input placeholder="https://open.spotify.com/..." value={spotifyUrl} onChange={(e) => setSpotifyUrl(e.target.value)}/>
<Label>Spotify URL</Label> </div>
<Input placeholder="https://open.spotify.com/..." value={spotifyUrl} onChange={e => setSpotifyUrl(e.target.value)} /> </div>
</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> </div>
</TabsContent> </div>
</Tabs> </div>
<div className="flex justify-center pt-4 shrink-0">
<Button className="w-[200px] cursor-pointer gap-2" onClick={handleSubmit}>
<ExternalLink className="h-4 w-4"/> Create Issue on GitHub
</Button>
</div>
</div>)}
{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>); </div>);
} }
+11 -5
View File
@@ -1,6 +1,6 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen, ImageDown, FileText } from "lucide-react"; import { Download, FolderOpen, ImageDown, FileText, XCircle } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { SearchAndSort } from "./SearchAndSort"; import { SearchAndSort } from "./SearchAndSort";
@@ -48,9 +48,9 @@ interface AlbumInfoProps {
isBulkDownloadingLyrics?: boolean; isBulkDownloadingLyrics?: boolean;
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
onSortChange: (value: string) => void; onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void; onToggleTrack: (id: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, 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; 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; onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void; onCheckAvailability?: (spotifyId: string) => void;
@@ -67,10 +67,16 @@ interface AlbumInfoProps {
external_urls: string; external_urls: string;
}) => void; }) => void;
onTrackClick?: (track: TrackMetadata) => void; onTrackClick?: (track: TrackMetadata) => void;
onBack?: () => void;
} }
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, }: AlbumInfoProps) { export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) {
return (<div className="space-y-6"> return (<div className="space-y-6">
<Card> <Card className="relative">
{onBack && (<div className="absolute top-4 right-4 z-10">
<Button variant="ghost" size="icon" onClick={onBack}>
<XCircle className="h-5 w-5"/>
</Button>
</div>)}
<CardContent className="px-6"> <CardContent className="px-6">
<div className="flex gap-6 items-start"> <div className="flex gap-6 items-start">
{albumInfo.images && (<img src={albumInfo.images} alt={albumInfo.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)} {albumInfo.images && (<img src={albumInfo.images} alt={albumInfo.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)}
+173 -45
View File
@@ -1,6 +1,6 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen, ImageDown, FileText, BadgeCheck } from "lucide-react"; import { Download, FolderOpen, ImageDown, FileText, BadgeCheck, XCircle, Filter } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { SearchAndSort } from "./SearchAndSort"; import { SearchAndSort } from "./SearchAndSort";
@@ -10,7 +10,10 @@ import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { downloadHeader, downloadGalleryImage, downloadAvatar } from "@/lib/api"; import { downloadHeader, downloadGalleryImage, downloadAvatar } from "@/lib/api";
import { getSettings } from "@/lib/settings"; import { getSettings } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { useState } from "react"; import { useState, useMemo } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
interface ArtistInfoProps { interface ArtistInfoProps {
artistInfo: { artistInfo: {
name: string; name: string;
@@ -31,6 +34,7 @@ interface ArtistInfoProps {
release_date: string; release_date: string;
album_type: string; album_type: string;
external_urls: string; external_urls: string;
total_tracks?: number;
}>; }>;
trackList: TrackMetadata[]; trackList: TrackMetadata[];
searchQuery: string; searchQuery: string;
@@ -63,9 +67,9 @@ interface ArtistInfoProps {
isBulkDownloadingLyrics?: boolean; isBulkDownloadingLyrics?: boolean;
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
onSortChange: (value: string) => void; onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void; onToggleTrack: (id: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, 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; 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; onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void; onCheckAvailability?: (spotifyId: string) => void;
@@ -87,12 +91,40 @@ interface ArtistInfoProps {
}) => void; }) => void;
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
onTrackClick?: (track: TrackMetadata) => void; onTrackClick?: (track: TrackMetadata) => void;
onBack?: () => void;
} }
export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onAlbumClick, onArtistClick, onPageChange, onTrackClick, }: ArtistInfoProps) { export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onAlbumClick, onArtistClick, onPageChange, onTrackClick, onBack, }: ArtistInfoProps) {
const [downloadingHeader, setDownloadingHeader] = useState(false); const [downloadingHeader, setDownloadingHeader] = useState(false);
const [downloadingAvatar, setDownloadingAvatar] = useState(false); const [downloadingAvatar, setDownloadingAvatar] = useState(false);
const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null); const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null);
const [downloadingAllGallery, setDownloadingAllGallery] = useState(false); const [downloadingAllGallery, setDownloadingAllGallery] = useState(false);
const [activeTab, setActiveTab] = useState<"albums" | "tracks" | "gallery">("albums");
const filteredAlbumGroups = useMemo(() => {
const albumTypeMap = new Map(albumList.map(a => [a.name, a.album_type]));
const albumGroups = trackList.reduce((acc, track) => {
if (!track.album_name)
return acc;
if (!acc[track.album_name]) {
acc[track.album_name] = {
count: 0,
tracks: [],
type: albumTypeMap.get(track.album_name) || "unknown"
};
}
acc[track.album_name].count++;
acc[track.album_name].tracks.push(track);
return acc;
}, {} as Record<string, {
count: number;
tracks: TrackMetadata[];
type: string;
}>);
return Object.entries(albumGroups).sort((a, b) => {
const dateA = a[1].tracks[0]?.release_date || "";
const dateB = b[1].tracks[0]?.release_date || "";
return dateB.localeCompare(dateA);
});
}, [trackList, albumList]);
const handleDownloadHeader = async () => { const handleDownloadHeader = async () => {
if (!artistInfo.header) if (!artistInfo.header)
return; return;
@@ -238,13 +270,19 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
setDownloadingAllGallery(false); setDownloadingAllGallery(false);
} }
}; };
const hasGallery = artistInfo.gallery && artistInfo.gallery.length > 0;
return (<div className="space-y-6"> return (<div className="space-y-6">
<Card className="overflow-hidden p-0"> <Card className="overflow-hidden p-0 relative">
{artistInfo.header ? (<> {artistInfo.header ? (<>
<div className="relative w-full h-64 bg-cover bg-center"> <div className="relative w-full h-64 bg-cover bg-center">
<div className="absolute inset-0 bg-cover bg-center" style={{ backgroundImage: `url(${artistInfo.header})` }}/> <div className="absolute inset-0 bg-cover bg-center" style={{ backgroundImage: `url(${artistInfo.header})` }}/>
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent"/> <div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent"/>
<div className="absolute top-4 right-4 z-10"> {onBack && (<div className="absolute top-4 right-4 z-10">
<Button variant="ghost" size="icon" onClick={onBack} className="text-white hover:bg-white/20 hover:text-white">
<XCircle className="h-5 w-5"/>
</Button>
</div>)}
<div className="absolute bottom-4 right-4 z-10">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button onClick={handleDownloadHeader} size="sm" variant="secondary" disabled={downloadingHeader} className="bg-white/10 hover:bg-white/20 text-white border-white/20"> <Button onClick={handleDownloadHeader} size="sm" variant="secondary" disabled={downloadingHeader} className="bg-white/10 hover:bg-white/20 text-white border-white/20">
@@ -277,20 +315,21 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
<p className="text-sm font-medium text-white/80">Artist</p> <p className="text-sm font-medium text-white/80">Artist</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="text-4xl font-bold text-white">{artistInfo.name}</h2> <h2 className="text-4xl font-bold text-white">{artistInfo.name}</h2>
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-blue-400 shrink-0"/>)} {artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-white fill-blue-400 shrink-0"/>)}
</div> </div>
{artistInfo.biography && (<p className="text-sm text-white/90">{artistInfo.biography}</p>)} {artistInfo.biography && (<p className="text-sm text-white/90 line-clamp-4">{artistInfo.biography}</p>)}
<div className="flex items-center gap-2 text-sm flex-wrap text-white/90"> <div className="flex items-center gap-2 text-sm flex-wrap text-white/90">
<span>{artistInfo.followers.toLocaleString()} followers</span> {artistInfo.rank && (<>
<span>#{artistInfo.rank} rank</span>
<span></span>
</>)}
<span>{artistInfo.followers.toLocaleString()} {artistInfo.followers === 1 ? "follower" : "followers"}</span>
{artistInfo.listeners && (<> {artistInfo.listeners && (<>
<span></span> <span></span>
<span>{artistInfo.listeners.toLocaleString()} listeners</span> <span>{artistInfo.listeners.toLocaleString()} {artistInfo.listeners === 1 ? "listener" : "listeners"}</span>
</>)} </>)}
{artistInfo.rank && (<> </div>
<span></span> <div className="flex items-center gap-2 text-sm flex-wrap text-white/90">
<span>#{artistInfo.rank} rank</span>
</>)}
<span></span>
<span>{albumList.length} {albumList.length === 1 ? "album" : "albums"}</span> <span>{albumList.length} {albumList.length === 1 ? "album" : "albums"}</span>
<span></span> <span></span>
<span>{trackList.length} {trackList.length === 1 ? "track" : "tracks"}</span> <span>{trackList.length} {trackList.length === 1 ? "track" : "tracks"}</span>
@@ -304,6 +343,11 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
</div> </div>
</div> </div>
</>) : (<CardContent className="px-6 py-6"> </>) : (<CardContent className="px-6 py-6">
{onBack && (<div className="absolute top-4 right-4 z-10">
<Button variant="ghost" size="icon" onClick={onBack}>
<XCircle className="h-5 w-5"/>
</Button>
</div>)}
<div className="flex gap-6 items-start"> <div className="flex gap-6 items-start">
{artistInfo.images && (<div className="relative group"> {artistInfo.images && (<div className="relative group">
<img src={artistInfo.images} alt={artistInfo.name} className="w-48 h-48 rounded-full shadow-lg object-cover border-4 border-white"/> <img src={artistInfo.images} alt={artistInfo.name} className="w-48 h-48 rounded-full shadow-lg object-cover border-4 border-white"/>
@@ -324,23 +368,24 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
<p className="text-sm font-medium">Artist</p> <p className="text-sm font-medium">Artist</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="text-4xl font-bold">{artistInfo.name}</h2> <h2 className="text-4xl font-bold">{artistInfo.name}</h2>
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-blue-500 shrink-0"/>)} {artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-white fill-blue-500 shrink-0"/>)}
</div> </div>
{artistInfo.biography && (<p className="text-sm text-muted-foreground">{artistInfo.biography}</p>)} {artistInfo.biography && (<p className="text-sm text-muted-foreground line-clamp-4">{artistInfo.biography}</p>)}
<div className="flex items-center gap-2 text-sm flex-wrap"> <div className="flex items-center gap-2 text-sm flex-wrap">
<span>{artistInfo.followers.toLocaleString()} followers</span> {artistInfo.rank && (<>
<span>#{artistInfo.rank} rank</span>
<span></span>
</>)}
<span>{artistInfo.followers.toLocaleString()} {artistInfo.followers === 1 ? "follower" : "followers"}</span>
{artistInfo.listeners && (<> {artistInfo.listeners && (<>
<span></span> <span></span>
<span>{artistInfo.listeners.toLocaleString()} listeners</span> <span>{artistInfo.listeners.toLocaleString()} {artistInfo.listeners === 1 ? "listener" : "listeners"}</span>
</>)}
{artistInfo.rank && (<>
<span></span>
<span>#{artistInfo.rank} rank</span>
</>)} </>)}
</div>
<div className="flex items-center gap-2 text-sm flex-wrap">
<span>{albumList.length} {albumList.length === 1 ? "album" : "albums"}</span>
<span></span> <span></span>
<span>{albumList.length} albums</span> <span>{trackList.length} {trackList.length === 1 ? "track" : "tracks"}</span>
<span></span>
<span>{trackList.length} tracks</span>
{artistInfo.genres.length > 0 && (<> {artistInfo.genres.length > 0 && (<>
<span></span> <span></span>
<span>{artistInfo.genres.join(", ")}</span> <span>{artistInfo.genres.join(", ")}</span>
@@ -351,9 +396,23 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
</CardContent>)} </CardContent>)}
</Card> </Card>
{artistInfo.gallery && artistInfo.gallery.length > 0 && (<div className="space-y-4"> <div className="border-b">
<div className="flex gap-6">
<button onClick={() => setActiveTab("albums")} className={`pb-3 text-sm font-medium transition-colors border-b-2 -mb-px hover:text-foreground ${activeTab === "albums" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`}>
Albums
</button>
<button onClick={() => setActiveTab("tracks")} className={`pb-3 text-sm font-medium transition-colors border-b-2 -mb-px hover:text-foreground ${activeTab === "tracks" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`}>
All Tracks
</button>
{hasGallery && (<button onClick={() => setActiveTab("gallery")} className={`pb-3 text-sm font-medium transition-colors border-b-2 -mb-px hover:text-foreground ${activeTab === "gallery" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`}>
Gallery
</button>)}
</div>
</div>
{activeTab === "gallery" && hasGallery && (<div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-2xl font-bold">Gallery ({artistInfo.gallery.length})</h3> <h3 className="text-2xl font-bold">Gallery ({artistInfo.gallery!.length})</h3>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button onClick={handleDownloadAllGallery} size="sm" variant="outline" disabled={downloadingAllGallery}> <Button onClick={handleDownloadAllGallery} size="sm" variant="outline" disabled={downloadingAllGallery}>
@@ -366,7 +425,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
</Tooltip> </Tooltip>
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{artistInfo.gallery.map((imageUrl, index) => (<div key={index} className="relative group"> {artistInfo.gallery!.map((imageUrl, index) => (<div key={index} className="relative group">
<div className="relative aspect-square rounded-md overflow-hidden shadow-md"> <div className="relative aspect-square rounded-md overflow-hidden shadow-md">
<img src={imageUrl} alt={`${artistInfo.name} gallery ${index + 1}`} className="w-full h-full object-cover"/> <img src={imageUrl} alt={`${artistInfo.name} gallery ${index + 1}`} className="w-full h-full object-cover"/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors flex items-center justify-center"> <div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors flex items-center justify-center">
@@ -386,29 +445,98 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
</div> </div>
</div>)} </div>)}
{albumList.length > 0 && (<div className="space-y-4"> {activeTab === "albums" && albumList.length > 0 && (<div className="space-y-4">
<h3 className="text-2xl font-bold">Discography</h3> <div className="flex items-center justify-between flex-wrap gap-2">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> <h3 className="text-2xl font-bold">Discography</h3>
{albumList.map((album) => (<div key={album.id} className="group cursor-pointer" onClick={() => onAlbumClick({ <div className="flex gap-2">
id: album.id, <Button onClick={onDownloadAll} size="sm" disabled={isDownloading}>
name: album.name, {isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
external_urls: album.external_urls, Download Discography
})}> </Button>
<div className="relative mb-4"> {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"/>)} {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> </div>
<h4 className="font-semibold truncate">{album.name}</h4> <h4 className="font-semibold truncate text-sm">{album.name}</h4>
<p className="text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground mt-1">
{album.release_date?.split("-")[0]} <span>{album.release_date?.split("-")[0]}</span>
</p> {album.total_tracks && (<>
</div>))} <span></span>
<span>{album.total_tracks} {album.total_tracks === 1 ? "track" : "tracks"}</span>
</>)}
</div>
</div>);
})}
</div> </div>
</div>)} </div>)}
{trackList.length > 0 && (<div className="space-y-4"> {activeTab === "tracks" && trackList.length > 0 && (<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2"> <div className="flex items-center justify-between flex-wrap gap-2">
<h3 className="text-2xl font-bold">All Tracks</h3> <h3 className="text-2xl font-bold">All Tracks</h3>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Filter className="h-4 w-4"/>
Filter Albums
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px] h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Select Albums</DialogTitle>
</DialogHeader>
<ScrollArea className="flex-1 pr-4">
<div className="space-y-4">
{filteredAlbumGroups.map(([albumName, data]) => {
const 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}> <Button onClick={onDownloadAll} size="sm" disabled={isDownloading}>
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)} {isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
Download All Download All
+38 -7
View File
@@ -4,7 +4,7 @@ import { Label } from "@/components/ui/label";
import { ToggleGroup, ToggleGroupItem, } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem, } from "@/components/ui/toggle-group";
import { Upload, X, CheckCircle2, AlertCircle, Trash2, FileMusic, WandSparkles, } from "lucide-react"; import { Upload, X, CheckCircle2, AlertCircle, Trash2, FileMusic, WandSparkles, } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { 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 { toastWithSound as toast } from "@/lib/toast-with-sound";
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime"; import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
interface AudioFile { 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 addFiles = useCallback(async (paths: string[]) => {
const validExtensions = [".mp3", ".flac"]; const validExtensions = [".mp3", ".flac"];
const m4aFiles = paths.filter((path) => { const m4aFiles = paths.filter((path) => {
@@ -243,7 +264,7 @@ export function AudioConverterPage() {
codec: outputFormat === "m4a" ? m4aCodec : "", codec: outputFormat === "m4a" ? m4aCodec : "",
}); });
setFiles((prev) => prev.map((f) => { 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) { if (result) {
return { return {
...f, ...f,
@@ -298,7 +319,11 @@ export function AudioConverterPage() {
{files.length > 0 && (<div className="flex gap-2"> {files.length > 0 && (<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleSelectFiles}> <Button variant="outline" size="sm" onClick={handleSelectFiles}>
<Upload className="h-4 w-4"/> <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>
<Button variant="outline" size="sm" onClick={clearFiles} disabled={converting}> <Button variant="outline" size="sm" onClick={clearFiles} disabled={converting}>
<Trash2 className="h-4 w-4"/> <Trash2 className="h-4 w-4"/>
@@ -329,10 +354,16 @@ export function AudioConverterPage() {
? "Drop your audio files here" ? "Drop your audio files here"
: "Drag and drop audio files here, or click the button below to select"} : "Drag and drop audio files here, or click the button below to select"}
</p> </p>
<Button onClick={handleSelectFiles} size="lg"> <div className="flex gap-3">
<Upload className="h-5 w-5"/> <Button onClick={handleSelectFiles} size="lg">
Select Files <Upload className="h-5 w-5"/>
</Button> 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"> <p className="text-xs text-muted-foreground mt-4 text-center">
Supported formats: FLAC, MP3 Supported formats: FLAC, MP3
</p> </p>
+22 -1
View File
@@ -1,7 +1,9 @@
import { useState, useEffect, useRef } from "react"; 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 { Button } from "@/components/ui/button";
import { logger, type LogEntry } from "@/lib/logger"; 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> = { const levelColors: Record<string, string> = {
info: "text-blue-500", info: "text-blue-500",
success: "text-green-500", success: "text-green-500",
@@ -51,10 +53,29 @@ export function DebugLoggerPage() {
console.error("Failed to copy logs:", err); 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"> return (<div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Debug Logs</h1> <h1 className="text-2xl font-bold">Debug Logs</h1>
<div className="flex items-center gap-2"> <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}> <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"/>} {copied ? <Check className="h-4 w-4"/> : <Copy className="h-4 w-4"/>}
Copy Copy
+38 -7
View File
@@ -1,9 +1,9 @@
import { useEffect, useState } from "react"; 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 { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { GetDownloadQueue, ClearCompletedDownloads, 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 { toastWithSound as toast } from "@/lib/toast-with-sound";
import { backend } from "../../wailsjs/go/models"; import { backend } from "../../wailsjs/go/models";
interface DownloadQueueProps { interface DownloadQueueProps {
@@ -59,6 +59,21 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
console.error("Failed to reset queue:", error); 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) => { const getStatusIcon = (status: string) => {
switch (status) { switch (status) {
case "downloading": case "downloading":
@@ -105,6 +120,15 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
return `${seconds}s`; 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}> return (<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[1200px] w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 [&>button]:hidden"> <DialogContent className="max-w-[1200px] w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 [&>button]:hidden">
<DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0"> <DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0">
@@ -115,6 +139,10 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
<Trash2 className="h-3 w-3"/> <Trash2 className="h-3 w-3"/>
Clear History Clear History
</Button>)} </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}> <Button variant="ghost" size="icon" className="h-7 w-7 rounded-full hover:bg-muted" onClick={onClose}>
<X className="h-4 w-4"/> <X className="h-4 w-4"/>
</Button> </Button>
@@ -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-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"/> <Clock className="h-3.5 w-3.5 text-muted-foreground"/>
<span className="text-muted-foreground">Queued:</span> <span className="text-muted-foreground">Queued:</span>
<span className="font-semibold">{queueInfo.queued_count}</span> <span className="font-semibold">{queueInfo.queued_count}</span>
</div> </div>
<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"/> <CheckCircle2 className="h-3.5 w-3.5 text-green-500"/>
<span className="text-muted-foreground">Completed:</span> <span className="text-muted-foreground">Completed:</span>
<span className="font-semibold">{queueInfo.completed_count}</span> <span className="font-semibold">{queueInfo.completed_count}</span>
</div> </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"/> <FileCheck className="h-3.5 w-3.5 text-yellow-500"/>
<span className="text-muted-foreground">Skipped:</span> <span className="text-muted-foreground">Skipped:</span>
<span className="font-semibold">{queueInfo.skipped_count}</span> <span className="font-semibold">{queueInfo.skipped_count}</span>
</div> </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"/> <XCircle className="h-3.5 w-3.5 text-red-500"/>
<span className="text-muted-foreground">Failed:</span> <span className="text-muted-foreground">Failed:</span>
<span className="font-semibold">{queueInfo.failed_count}</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"> {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"/> <Download className="h-12 w-12 mx-auto mb-3 opacity-20"/>
<p>No downloads in queue</p> <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="flex items-start gap-3">
<div className="mt-1">{getStatusIcon(item.status)}</div> <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>
</div> </div>
<p className="text-muted-foreground"> <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> </p>
</div> </div>
</div>); </div>);
+519 -250
View File
@@ -1,12 +1,12 @@
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Trash2, ExternalLink, Search, ArrowUpDown, History, Play, Pause } from "lucide-react"; import { Trash2, ExternalLink, Search, ArrowUpDown, History, Play, Pause, Database, CloudUpload, Music2, Disc3, ListMusic, UserRound } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination"; import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
import { GetDownloadHistory, ClearDownloadHistory, GetPreviewURL } from "../../wailsjs/go/main/App"; import { GetDownloadHistory, ClearDownloadHistory, GetPreviewURL, GetFetchHistory, DeleteDownloadHistoryItem, DeleteFetchHistoryItem, ClearFetchHistoryByType } from "../../wailsjs/go/main/App";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { openExternal } from "@/lib/utils"; import { openExternal } from "@/lib/utils";
const formatDate = (timestamp: number) => { const formatDate = (timestamp: number) => {
@@ -19,7 +19,7 @@ const formatDate = (timestamp: number) => {
const seconds = String(date.getSeconds()).padStart(2, '0'); const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}; };
interface HistoryItem { interface DownloadHistoryItem {
id: string; id: string;
spotify_id: string; spotify_id: string;
title: string; title: string;
@@ -32,65 +32,77 @@ interface HistoryItem {
path: string; path: string;
timestamp: number; timestamp: number;
} }
export function HistoryPage() { interface FetchHistoryItem {
const [history, setHistory] = useState<HistoryItem[]>([]); id: string;
const [filteredHistory, setFilteredHistory] = useState<HistoryItem[]>([]); url: string;
const [showClearConfirm, setShowClearConfirm] = useState(false); type: string;
const [searchQuery, setSearchQuery] = useState(""); name: string;
const [sortBy, setSortBy] = useState("default"); info: string;
const [currentPage, setCurrentPage] = useState(1); image: string;
data: string;
timestamp: number;
}
interface HistoryPageProps {
onHistorySelect?: (cachedData: string) => void;
}
export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
const [activeTab, setActiveTab] = useState("downloads");
const [downloadHistory, setDownloadHistory] = useState<DownloadHistoryItem[]>([]);
const [filteredDownloadHistory, setFilteredDownloadHistory] = useState<DownloadHistoryItem[]>([]);
const [showClearDownloadConfirm, setShowClearDownloadConfirm] = useState(false);
const [downloadSearchQuery, setDownloadSearchQuery] = useState("");
const [downloadSortBy, setDownloadSortBy] = useState("default");
const [downloadCurrentPage, setDownloadCurrentPage] = useState(1);
const [playingPreviewId, setPlayingPreviewId] = useState<string | null>(null); const [playingPreviewId, setPlayingPreviewId] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
const [fetchHistory, setFetchHistory] = useState<FetchHistoryItem[]>([]);
const [filteredFetchHistory, setFilteredFetchHistory] = useState<FetchHistoryItem[]>([]);
const [activeFetchTab, setActiveFetchTab] = useState("track");
const [showClearFetchConfirm, setShowClearFetchConfirm] = useState(false);
const [fetchSearchQuery, setFetchSearchQuery] = useState("");
const [fetchCurrentPage, setFetchCurrentPage] = useState(1);
const ITEMS_PER_PAGE = 50; const ITEMS_PER_PAGE = 50;
const fetchHistory = async () => { const fetchDownloadHistory = async () => {
try { try {
const items = await GetDownloadHistory(); const items = await GetDownloadHistory();
setHistory(items || []); setDownloadHistory(items || []);
} }
catch (err) { catch (err) {
console.error("Failed to fetch history:", err); console.error("Failed to fetch download history:", err);
}
};
const fetchFetchHistory = async () => {
try {
const items = await GetFetchHistory();
setFetchHistory(items || []);
}
catch (err) {
console.error("Failed to fetch fetch history:", err);
} }
}; };
useEffect(() => { useEffect(() => {
fetchHistory(); if (activeTab === "downloads") {
const interval = setInterval(fetchHistory, 5000); fetchDownloadHistory();
const interval = setInterval(fetchDownloadHistory, 5000);
return () => clearInterval(interval);
}
else {
fetchFetchHistory();
const interval = setInterval(fetchFetchHistory, 5000);
return () => clearInterval(interval);
}
}, [activeTab]);
useEffect(() => {
return () => { return () => {
clearInterval(interval);
if (audioRef.current) { if (audioRef.current) {
audioRef.current.pause(); audioRef.current.pause();
} }
}; };
}, []); }, []);
const handlePreview = async (id: string, spotifyId: string) => {
if (playingPreviewId === id) {
audioRef.current?.pause();
setPlayingPreviewId(null);
return;
}
if (audioRef.current) {
audioRef.current.pause();
}
try {
const url = await GetPreviewURL(spotifyId);
if (url) {
const audio = new Audio(url);
audioRef.current = audio;
audio.volume = 0.5;
audio.onended = () => setPlayingPreviewId(null);
audio.play();
setPlayingPreviewId(id);
}
} catch (e) {
console.error("Failed to play preview:", e);
}
};
useEffect(() => { useEffect(() => {
let result = [...history]; let result = [...downloadHistory];
if (searchQuery) { if (downloadSearchQuery) {
const query = searchQuery.toLowerCase(); const query = downloadSearchQuery.toLowerCase();
result = result.filter(item => item.title.toLowerCase().includes(query) || result = result.filter(item => item.title.toLowerCase().includes(query) ||
item.artists.toLowerCase().includes(query) || item.artists.toLowerCase().includes(query) ||
item.album.toLowerCase().includes(query)); item.album.toLowerCase().includes(query));
@@ -104,38 +116,86 @@ export function HistoryPage() {
return 0; return 0;
}; };
result.sort((a, b) => { result.sort((a, b) => {
switch (sortBy) { switch (downloadSortBy) {
case "default": case "default":
case "date_desc": case "date_desc": return b.timestamp - a.timestamp;
return b.timestamp - a.timestamp; case "date_asc": return a.timestamp - b.timestamp;
case "date_asc": case "title_asc": return a.title.localeCompare(b.title);
return a.timestamp - b.timestamp; case "title_desc": return b.title.localeCompare(a.title);
case "title_asc": case "artist_asc": return a.artists.localeCompare(b.artists);
return a.title.localeCompare(b.title); case "artist_desc": return b.artists.localeCompare(a.artists);
case "title_desc": case "duration_asc": return parseDuration(a.duration_str) - parseDuration(b.duration_str);
return b.title.localeCompare(a.title); case "duration_desc": return parseDuration(b.duration_str) - parseDuration(a.duration_str);
case "artist_asc": default: return 0;
return a.artists.localeCompare(b.artists);
case "artist_desc":
return b.artists.localeCompare(a.artists);
case "duration_asc":
return parseDuration(a.duration_str) - parseDuration(b.duration_str);
case "duration_desc":
return parseDuration(b.duration_str) - parseDuration(a.duration_str);
default:
return 0;
} }
}); });
setFilteredHistory(result); setFilteredDownloadHistory(result);
setCurrentPage(1); }, [downloadHistory, downloadSearchQuery, downloadSortBy]);
}, [history, searchQuery, sortBy]); useEffect(() => {
const totalPages = Math.ceil(filteredHistory.length / ITEMS_PER_PAGE); setDownloadCurrentPage(1);
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; }, [downloadSearchQuery, downloadSortBy]);
const paginatedHistory = filteredHistory.slice(startIndex, startIndex + ITEMS_PER_PAGE); useEffect(() => {
const getPaginationPages = (current: number, total: number): (number | 'ellipsis')[] => { let result = [...fetchHistory];
if (total <= 10) { if (activeFetchTab !== "all") {
return Array.from({ length: total }, (_, i) => i + 1); 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')[] = []; const pages: (number | 'ellipsis')[] = [];
pages.push(1); pages.push(1);
if (current <= 7) { if (current <= 7) {
@@ -159,188 +219,397 @@ export function HistoryPage() {
} }
return pages; return pages;
}; };
const handleClearHistory = async () => { const renderDownloadHistory = () => {
await ClearDownloadHistory(); const totalPages = Math.ceil(filteredDownloadHistory.length / ITEMS_PER_PAGE);
fetchHistory(); const startIndex = (downloadCurrentPage - 1) * ITEMS_PER_PAGE;
setShowClearConfirm(false); const paginated = filteredDownloadHistory.slice(startIndex, startIndex + ITEMS_PER_PAGE);
return (<div className="space-y-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold tracking-tight">Downloads</h2>
{downloadHistory.length > 0 && (<Badge variant="secondary" className="font-mono">
{downloadHistory.length.toLocaleString('en-US')}
</Badge>)}
</div>
<Button variant="outline" size="sm" onClick={() => setShowClearDownloadConfirm(true)} disabled={downloadHistory.length === 0} className="cursor-pointer gap-2">
<Trash2 className="h-4 w-4"/> Clear All
</Button>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground"/>
<Input placeholder="Search downloads..." value={downloadSearchQuery} onChange={(e) => setDownloadSearchQuery(e.target.value)} className="pl-8 h-9"/>
</div>
<Select value={downloadSortBy} onValueChange={setDownloadSortBy}>
<SelectTrigger className="w-[180px] h-9">
<ArrowUpDown className="mr-2 h-4 w-4"/>
<SelectValue placeholder="Sort by"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="date_desc">Date (Newest)</SelectItem>
<SelectItem value="date_asc">Date (Oldest)</SelectItem>
<SelectItem value="title_asc">Title (A-Z)</SelectItem>
<SelectItem value="title_desc">Title (Z-A)</SelectItem>
<SelectItem value="artist_asc">Artist (A-Z)</SelectItem>
<SelectItem value="artist_desc">Artist (Z-A)</SelectItem>
<SelectItem value="duration_asc">Duration (Short)</SelectItem>
<SelectItem value="duration_desc">Duration (Long)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="rounded-md border overflow-hidden">
{paginated.length === 0 ? (<div className="flex flex-col items-center justify-center p-16 text-center text-muted-foreground gap-3">
<div className="rounded-full bg-muted/50 p-4 ring-8 ring-muted/20">
<History className="h-10 w-10 opacity-40"/>
</div>
<div className="space-y-1">
<p className="font-medium text-foreground/80">No download history</p>
<p className="text-sm">Your downloaded tracks will appear here.</p>
</div>
</div>) : (<table className="w-full table-fixed">
<thead>
<tr className="border-b bg-muted/50">
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground w-12 text-xs uppercase">#</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground text-xs uppercase">Title</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell text-xs uppercase w-1/4">Album</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-32 text-xs uppercase">Format</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-16 text-xs uppercase text-nowrap">Dur</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell w-36 text-xs uppercase text-nowrap">Downloaded At</th>
<th className="h-10 px-4 text-center align-middle font-medium text-muted-foreground w-32 text-xs uppercase text-nowrap">Actions</th>
</tr>
</thead>
<tbody>
{paginated.map((item, index) => (<tr key={item.id} className="border-b transition-colors hover:bg-muted/50">
<td className="p-3 align-middle text-sm text-muted-foreground text-left font-mono">
{startIndex + index + 1}
</td>
<td className="p-3 align-middle min-w-0">
<div className="flex items-center gap-3 min-w-0">
<img src={item.cover_url || "https://placehold.co/300?text=No+Cover"} alt={item.album} className="h-10 w-10 rounded shrink-0 bg-secondary object-cover" onError={(e) => { (e.target as HTMLImageElement).src = "https://placehold.co/300?text=No+Cover"; }}/>
<div className="flex flex-col min-w-0 flex-1">
<span className="font-medium text-sm truncate">{item.title}</span>
<span className="text-xs text-muted-foreground truncate">{item.artists}</span>
</div>
</div>
</td>
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
<div className="truncate">{item.album}</div>
</td>
<td className="p-3 align-middle text-left hidden lg:table-cell">
<div className="flex flex-col items-start gap-1">
<span className="text-xs font-bold text-foreground">
{['HI_RES_LOSSLESS', 'LOSSLESS', '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"> return (<div className="space-y-6">
<div className="flex flex-col gap-4"> <div className="flex items-center gap-4">
<div className="flex items-center justify-between"> <h1 className="text-2xl font-bold">History</h1>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">Download History</h2>
{history.length > 0 && (<Badge variant="secondary" className="font-mono">
{history.length.toLocaleString('en-US')}
</Badge>)}
</div>
<Button variant="outline" size="sm" onClick={() => setShowClearConfirm(true)} disabled={history.length === 0} className="cursor-pointer gap-2">
<Trash2 className="h-4 w-4" /> Clear
</Button>
</div> </div>
<div className="flex items-center gap-2"> <div className="border-b">
<div className="relative flex-1"> <div className="flex gap-6">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> <button onClick={() => setActiveTab("downloads")} className={`pb-3 text-sm font-medium transition-colors border-b-2 -mb-px hover:text-foreground ${activeTab === "downloads" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`}>
<Input placeholder="Search history..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pl-8 h-9" /> Downloads
</button>
<button onClick={() => setActiveTab("fetches")} className={`pb-3 text-sm font-medium transition-colors border-b-2 -mb-px hover:text-foreground ${activeTab === "fetches" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`}>
Fetches
</button>
</div> </div>
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[180px] h-9">
<ArrowUpDown className="mr-2 h-4 w-4" />
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="date_desc">Date (Newest)</SelectItem>
<SelectItem value="date_asc">Date (Oldest)</SelectItem>
<SelectItem value="title_asc">Title (A-Z)</SelectItem>
<SelectItem value="title_desc">Title (Z-A)</SelectItem>
<SelectItem value="artist_asc">Artist (A-Z)</SelectItem>
<SelectItem value="artist_desc">Artist (Z-A)</SelectItem>
<SelectItem value="duration_asc">Duration (Short)</SelectItem>
<SelectItem value="duration_desc">Duration (Long)</SelectItem>
</SelectContent>
</Select>
</div> </div>
</div>
<div className="rounded-md border overflow-hidden"> {activeTab === "downloads" && (<div className="mt-6">
{paginatedHistory.length === 0 ? (<div className="flex flex-col items-center justify-center p-16 text-center text-muted-foreground gap-3"> {renderDownloadHistory()}
<div className="rounded-full bg-muted/50 p-4 ring-8 ring-muted/20"> </div>)}
<History className="h-10 w-10 opacity-40" />
</div>
<div className="space-y-1">
<p className="font-medium text-foreground/80">No download history</p>
<p className="text-sm">Your downloaded tracks will appear here.</p>
</div>
</div>) : (<table className="w-full table-fixed">
<thead>
<tr className="border-b bg-muted/50">
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground w-12 text-xs uppercase">#</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground text-xs uppercase">Title</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell text-xs uppercase w-1/4">Album</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-32 text-xs uppercase">Format</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-16 text-xs uppercase text-nowrap">Dur</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell w-40 text-xs uppercase text-nowrap">Downloaded At</th>
<th className="h-10 px-4 text-center align-middle font-medium text-muted-foreground w-20 text-xs uppercase text-nowrap">Actions</th>
</tr>
</thead>
<tbody>
{paginatedHistory.map((item, index) => (<tr key={item.id} className="border-b transition-colors hover:bg-muted/50">
<td className="p-3 align-middle text-sm text-muted-foreground text-left font-mono">
{startIndex + index + 1}
</td>
<td className="p-3 align-middle min-w-0">
<div className="flex items-center gap-3 min-w-0">
<img src={item.cover_url || "https://placehold.co/300?text=No+Cover"} alt={item.album} className="h-10 w-10 rounded shrink-0 bg-secondary object-cover" onError={(e) => { (e.target as HTMLImageElement).src = "https://placehold.co/300?text=No+Cover"; }} />
<div className="flex flex-col min-w-0 flex-1">
<span className="font-medium text-sm truncate">{item.title}</span>
<span className="text-xs text-muted-foreground truncate">{item.artists}</span>
</div>
</div>
</td>
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
<div className="truncate">{item.album}</div>
</td>
<td className="p-3 align-middle text-left hidden lg:table-cell">
<div className="flex flex-col items-start gap-1">
<span className="text-xs font-bold text-foreground">
{['HI_RES_LOSSLESS', 'LOSSLESS'].includes(item.format) ? 'FLAC' : item.format}
</span>
{item.quality && <span className="text-[11px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>}
</div>
</td>
<td className="p-3 align-middle text-sm text-muted-foreground text-left hidden xl:table-cell font-mono">
{item.duration_str}
</td>
<td className="p-3 align-middle text-xs text-muted-foreground hidden md:table-cell whitespace-nowrap">
<div className="flex flex-col">
<span>{formatDate(item.timestamp).split(' ')[0]}</span>
<span className="text-[10px] text-muted-foreground">{formatDate(item.timestamp).split(' ')[1]}</span>
</div>
</td>
<td className="p-3 align-middle text-center">
<div className="flex items-center justify-center gap-1">
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => handlePreview(item.id, item.spotify_id)} disabled={!item.spotify_id}>
{playingPreviewId === item.id ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{playingPreviewId === item.id ? "Pause Preview" : "Play Preview"}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider> {activeTab === "fetches" && (<div className="mt-6">
<Tooltip delayDuration={0}> {renderFetchHistory()}
<TooltipTrigger asChild> </div>)}
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => openExternal(`https://open.spotify.com/track/${item.spotify_id}`)}>
<ExternalLink className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Open in Spotify</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</td>
</tr>))}
</tbody>
</table>)}
</div>
{ <Dialog open={showClearDownloadConfirm} onOpenChange={setShowClearDownloadConfirm}>
totalPages > 1 && (<Pagination> <DialogContent className="max-w-md [&>button]:hidden">
<PaginationContent> <DialogHeader>
<PaginationItem> <DialogTitle>Clear Download History?</DialogTitle>
<PaginationPrevious href="#" onClick={(e) => { <DialogDescription>
e.preventDefault(); This will remove all entries from your download history. This action cannot be undone.
if (currentPage > 1) Note: The actual downloaded files will NOT be deleted.
setCurrentPage(currentPage - 1); </DialogDescription>
}} className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"} /> </DialogHeader>
</PaginationItem> <DialogFooter>
<Button variant="outline" onClick={() => setShowClearDownloadConfirm(false)} className="cursor-pointer">Cancel</Button>
<Button variant="destructive" onClick={handleClearDownloadHistory} className="cursor-pointer">
Clear History
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{getPaginationPages(currentPage, totalPages).map((page, index) => (page === 'ellipsis' ? (<PaginationItem key={`ellipsis-${index}`}> <Dialog open={showClearFetchConfirm} onOpenChange={setShowClearFetchConfirm}>
<PaginationEllipsis /> <DialogContent className="max-w-md [&>button]:hidden">
</PaginationItem>) : (<PaginationItem key={page}> <DialogHeader>
<PaginationLink href="#" onClick={(e) => { <DialogTitle>Clear {activeFetchTab.charAt(0).toUpperCase() + activeFetchTab.slice(1)} History?</DialogTitle>
e.preventDefault(); <DialogDescription>
setCurrentPage(page); This will remove all {activeFetchTab} entries from your fetch history cache.
}} isActive={currentPage === page} className="cursor-pointer"> </DialogDescription>
{page} </DialogHeader>
</PaginationLink> <DialogFooter>
</PaginationItem>)))} <Button variant="outline" onClick={() => setShowClearFetchConfirm(false)} className="cursor-pointer">Cancel</Button>
<Button variant="destructive" onClick={handleClearFetchHistory} className="cursor-pointer">
<PaginationItem> Clear History
<PaginationNext href="#" onClick={(e) => { </Button>
e.preventDefault(); </DialogFooter>
if (currentPage < totalPages) </DialogContent>
setCurrentPage(currentPage + 1); </Dialog>
}} className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"} /> </div>);
</PaginationItem>
</PaginationContent>
</Pagination>)
}
<Dialog open={showClearConfirm} onOpenChange={setShowClearConfirm}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>Clear Download History?</DialogTitle>
<DialogDescription>
This will remove all entries from your download history. This action cannot be undone.
Note: The actual downloaded files will NOT be deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowClearConfirm(false)} className="cursor-pointer">Cancel</Button>
<Button variant="destructive" onClick={handleClearHistory} className="cursor-pointer">
Clear History
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div >);
} }
@@ -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="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> <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>); </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 { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen, ImageDown, FileText } from "lucide-react"; import { Download, FolderOpen, ImageDown, FileText, XCircle } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { SearchAndSort } from "./SearchAndSort"; import { SearchAndSort } from "./SearchAndSort";
@@ -54,9 +54,9 @@ interface PlaylistInfoProps {
isBulkDownloadingLyrics?: boolean; isBulkDownloadingLyrics?: boolean;
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
onSortChange: (value: string) => void; onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void; onToggleTrack: (id: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, 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; 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; onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void; onCheckAvailability?: (spotifyId: string) => void;
@@ -78,10 +78,16 @@ interface PlaylistInfoProps {
external_urls: string; external_urls: string;
}) => void; }) => void;
onTrackClick: (track: TrackMetadata) => void; onTrackClick: (track: TrackMetadata) => void;
onBack?: () => void;
} }
export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, }: PlaylistInfoProps) { export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, onBack, }: PlaylistInfoProps) {
return (<div className="space-y-6"> return (<div className="space-y-6">
<Card> <Card className="relative">
{onBack && (<div className="absolute top-4 right-4 z-10">
<Button variant="ghost" size="icon" onClick={onBack}>
<XCircle className="h-5 w-5"/>
</Button>
</div>)}
<CardContent className="px-6"> <CardContent className="px-6">
<div className="flex gap-6 items-start"> <div className="flex gap-6 items-start">
{playlistInfo.cover && (<img src={playlistInfo.cover} alt={playlistInfo.owner.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)} {playlistInfo.cover && (<img src={playlistInfo.cover} alt={playlistInfo.owner.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)}
@@ -100,7 +106,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
{playlistInfo.tracks.total.toLocaleString()} {playlistInfo.tracks.total === 1 ? "track" : "tracks"} {playlistInfo.tracks.total.toLocaleString()} {playlistInfo.tracks.total === 1 ? "track" : "tracks"}
</span> </span>
<span></span> <span></span>
<span>{playlistInfo.followers.total.toLocaleString()} followers</span> <span>{playlistInfo.followers.total.toLocaleString()} {playlistInfo.followers.total === 1 ? "follower" : "followers"}</span>
</div> </div>
</div> </div>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
@@ -33,6 +33,7 @@ export function SearchAndSort({ searchQuery, sortBy, onSearchChange, onSortChang
<SelectItem value="plays-desc">Plays (High)</SelectItem> <SelectItem value="plays-desc">Plays (High)</SelectItem>
<SelectItem value="downloaded">Downloaded</SelectItem> <SelectItem value="downloaded">Downloaded</SelectItem>
<SelectItem value="not-downloaded">Not Downloaded</SelectItem> <SelectItem value="not-downloaded">Not Downloaded</SelectItem>
<SelectItem value="failed">Failed Downloads</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div>); </div>);
+400 -107
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { InputWithContext } from "@/components/ui/input-with-context"; import { InputWithContext } from "@/components/ui/input-with-context";
import { CloudDownload, Info, XCircle, Link, Search, X, ChevronDown } from "lucide-react"; import { CloudDownload, XCircle, Link, Search, X, ChevronDown, } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { FetchHistory } from "@/components/FetchHistory"; import { FetchHistory } from "@/components/FetchHistory";
@@ -9,6 +9,219 @@ import type { HistoryItem } from "@/components/FetchHistory";
import { SearchSpotify, SearchSpotifyByType } from "../../wailsjs/go/main/App"; import { SearchSpotify, SearchSpotifyByType } from "../../wailsjs/go/main/App";
import { backend } from "../../wailsjs/go/models"; import { backend } from "../../wailsjs/go/models";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTypingEffect } from "@/hooks/useTypingEffect";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
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"; type ResultTab = "tracks" | "albums" | "artists" | "playlists";
const RECENT_SEARCHES_KEY = "spotiflac_recent_searches"; const RECENT_SEARCHES_KEY = "spotiflac_recent_searches";
const MAX_RECENT_SEARCHES = 8; const MAX_RECENT_SEARCHES = 8;
@@ -25,8 +238,10 @@ interface SearchBarProps {
hasResult: boolean; hasResult: boolean;
searchMode: boolean; searchMode: boolean;
onSearchModeChange: (isSearch: boolean) => void; onSearchModeChange: (isSearch: boolean) => void;
region: string;
onRegionChange: (region: string) => void;
} }
export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, }: SearchBarProps) { export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, region, onRegionChange, }: SearchBarProps) {
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<backend.SearchResponse | null>(null); const [searchResults, setSearchResults] = useState<backend.SearchResponse | null>(null);
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
@@ -40,7 +255,11 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
artists: false, artists: false,
playlists: false, playlists: false,
}); });
const [showInvalidUrlDialog, setShowInvalidUrlDialog] = useState(false);
const [invalidUrl, setInvalidUrl] = useState("");
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const placeholders = searchMode ? SEARCH_PLACEHOLDERS : FETCH_PLACEHOLDERS;
const placeholderText = useTypingEffect(placeholders);
useEffect(() => { useEffect(() => {
try { try {
const saved = localStorage.getItem(RECENT_SEARCHES_KEY); const saved = localStorage.getItem(RECENT_SEARCHES_KEY);
@@ -93,7 +312,10 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
searchTimeoutRef.current = setTimeout(async () => { searchTimeoutRef.current = setTimeout(async () => {
setIsSearching(true); setIsSearching(true);
try { try {
const results = await SearchSpotify({ query: searchQuery, limit: SEARCH_LIMIT }); const results = await SearchSpotify({
query: searchQuery,
limit: SEARCH_LIMIT,
});
setSearchResults(results); setSearchResults(results);
setLastSearchedQuery(searchQuery.trim()); setLastSearchedQuery(searchQuery.trim());
saveRecentSearch(searchQuery.trim()); saveRecentSearch(searchQuery.trim());
@@ -149,10 +371,18 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
if (!prev) if (!prev)
return prev; return prev;
const updated = new backend.SearchResponse({ const updated = new backend.SearchResponse({
tracks: activeTab === "tracks" ? [...prev.tracks, ...moreResults] : prev.tracks, tracks: activeTab === "tracks"
albums: activeTab === "albums" ? [...prev.albums, ...moreResults] : prev.albums, ? [...prev.tracks, ...moreResults]
artists: activeTab === "artists" ? [...prev.artists, ...moreResults] : prev.artists, : prev.tracks,
playlists: activeTab === "playlists" ? [...prev.playlists, ...moreResults] : prev.playlists, 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; return updated;
}); });
@@ -169,6 +399,35 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
setIsLoadingMore(false); 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) => { const handleResultClick = (externalUrl: string) => {
onSearchModeChange(false); onSearchModeChange(false);
onFetchUrl(externalUrl); onFetchUrl(externalUrl);
@@ -178,18 +437,23 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
const seconds = Math.floor((ms % 60000) / 1000); const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, "0")}`; return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}; };
const hasAnyResults = searchResults && (searchResults.tracks.length > 0 || const hasAnyResults = searchResults &&
searchResults.albums.length > 0 || (searchResults.tracks.length > 0 ||
searchResults.artists.length > 0 || searchResults.albums.length > 0 ||
searchResults.playlists.length > 0); searchResults.artists.length > 0 ||
searchResults.playlists.length > 0);
const getTabCount = (tab: ResultTab): number => { const getTabCount = (tab: ResultTab): number => {
if (!searchResults) if (!searchResults)
return 0; return 0;
switch (tab) { switch (tab) {
case "tracks": return searchResults.tracks.length; case "tracks":
case "albums": return searchResults.albums.length; return searchResults.tracks.length;
case "artists": return searchResults.artists.length; case "albums":
case "playlists": return searchResults.playlists.length; return searchResults.albums.length;
case "artists":
return searchResults.artists.length;
case "playlists":
return searchResults.playlists.length;
} }
}; };
const tabs: { const tabs: {
@@ -202,57 +466,51 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
{ key: "playlists", label: "Playlists" }, { key: "playlists", label: "Playlists" },
]; ];
return (<div className="space-y-4"> return (<div className="space-y-4">
<div className="space-y-2"> <div className="flex gap-2">
<div className="flex items-center gap-2"> <Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center bg-muted rounded-md p-1"> <Button variant="outline" size="icon" className="shrink-0" onClick={() => onSearchModeChange(!searchMode)}>
<button type="button" onClick={() => onSearchModeChange(false)} className={cn("flex items-center gap-1.5 px-2.5 py-1 rounded text-sm font-medium transition-colors cursor-pointer", !searchMode {searchMode ? (<Link className="h-4 w-4"/>) : (<Search className="h-4 w-4"/>)}
? "bg-background text-foreground shadow-sm" </Button>
: "text-muted-foreground hover:text-foreground")}> </TooltipTrigger>
<Link className="h-3.5 w-3.5"/> <TooltipContent>
URL <p>{searchMode ? "Fetch Mode" : "Search Mode"}</p>
</button> </TooltipContent>
<button type="button" onClick={() => onSearchModeChange(true)} className={cn("flex items-center gap-1.5 px-2.5 py-1 rounded text-sm font-medium transition-colors cursor-pointer", searchMode </Tooltip>
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground")}>
<Search className="h-3.5 w-3.5"/>
Search
</button>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-4 w-4 text-muted-foreground cursor-help"/>
</TooltipTrigger>
<TooltipContent side="right">
{!searchMode ? (<>
<p>Supports track, album, playlist, and artist URLs</p>
<p className="mt-1">Note: Playlist must be public (not private)</p>
</>) : (<p>Search for tracks, albums, artists, or playlists</p>)}
</TooltipContent>
</Tooltip>
</div>
<div className="flex gap-2"> <div className="relative flex-1">
<div className="relative flex-1"> {!searchMode ? (<>
{!searchMode ? (<> <InputWithContext id="spotify-url" placeholder={placeholderText} value={url} onChange={(e) => onUrlChange(e.target.value)} onPaste={handlePaste} onKeyDown={(e) => e.key === "Enter" && handleFetchWithValidation()} className="pr-8"/>
<InputWithContext id="spotify-url" placeholder="https://open.spotify.com/..." value={url} onChange={(e) => onUrlChange(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onFetch()} className="pr-8"/> {url && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => onUrlChange("")}>
{url && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => onUrlChange("")}> <XCircle className="h-4 w-4"/>
<XCircle className="h-4 w-4"/> </button>)}
</button>)} </>) : (<>
</>) : (<> <InputWithContext id="spotify-search" placeholder={placeholderText} value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pr-8"/>
<InputWithContext id="spotify-search" placeholder="Search tracks, albums, artists..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pr-8"/> {searchQuery && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => {
{searchQuery && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => {
setSearchQuery(""); setSearchQuery("");
setSearchResults(null); setSearchResults(null);
setLastSearchedQuery(""); setLastSearchedQuery("");
}}> }}>
<XCircle className="h-4 w-4"/> <XCircle className="h-4 w-4"/>
</button>)} </button>)}
</>)} </>)}
</div> </div>
{!searchMode && (<Button onClick={onFetch} disabled={loading}> {!searchMode && (<>
<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 ? (<> {loading ? (<>
<Spinner /> <Spinner />
Fetching... Fetching...
@@ -260,15 +518,13 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
<CloudDownload className="h-4 w-4"/> <CloudDownload className="h-4 w-4"/>
Fetch Fetch
</>)} </>)}
</Button>)} </Button>
</div> </>)}
</div> </div>
{!searchMode && !hasResult && (<FetchHistory history={history} onSelect={onHistorySelect} onRemove={onHistoryRemove}/>)} {!searchMode && !hasResult && (<FetchHistory history={history} onSelect={onHistorySelect} onRemove={onHistoryRemove}/>)}
{searchMode && (<div className="space-y-4"> {searchMode && (<div className="space-y-4">
{!searchQuery && !searchResults && recentSearches.length > 0 && (<div className="space-y-2"> {!searchQuery && !searchResults && recentSearches.length > 0 && (<div className="space-y-2">
<p className="text-sm text-muted-foreground">Recent Searches</p> <p className="text-sm text-muted-foreground">Recent Searches</p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -294,7 +550,6 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
</div>)} </div>)}
{!isSearching && hasAnyResults && (<> {!isSearching && hasAnyResults && (<>
<div className="flex gap-1 border-b"> <div className="flex gap-1 border-b">
{tabs.map((tab) => { {tabs.map((tab) => {
const count = getTabCount(tab.key); const count = getTabCount(tab.key);
@@ -308,54 +563,61 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
})} })}
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
{activeTab === "tracks" &&
{activeTab === "tracks" && searchResults?.tracks.map((track) => (<button key={track.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(track.external_urls)}> searchResults?.tracks.map((track) => (<button key={track.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(track.external_urls)}>
{track.images ? (<img src={track.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)} {track.images ? (<img src={track.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium truncate">{track.name}</p> <div className="flex items-center gap-1.5 min-w-0">
<p className="text-sm text-muted-foreground truncate">{track.artists}</p> <p className="font-medium truncate">{track.name}</p>
</div> {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">
<span className="text-sm text-muted-foreground shrink-0"> E
{formatDuration(track.duration_ms || 0)} </span>)}
</span> </div>
</button>))} <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" &&
{activeTab === "albums" && searchResults?.albums.map((album) => (<button key={album.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(album.external_urls)}> searchResults?.albums.map((album) => (<button key={album.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(album.external_urls)}>
{album.images ? (<img src={album.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)} {album.images ? (<img src={album.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium truncate">{album.name}</p> <p className="font-medium truncate">{album.name}</p>
<p className="text-sm text-muted-foreground truncate">{album.artists}</p> <p className="text-sm text-muted-foreground truncate">
</div> {album.artists}
<span className="text-sm text-muted-foreground shrink-0"> </p>
{album.release_date || ""} </div>
</span> <span className="text-sm text-muted-foreground shrink-0">
</button>))} {album.release_date || ""}
</span>
</button>))}
{activeTab === "artists" &&
{activeTab === "artists" && searchResults?.artists.map((artist) => (<button key={artist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(artist.external_urls)}> searchResults?.artists.map((artist) => (<button key={artist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(artist.external_urls)}>
{artist.images ? (<img src={artist.images} alt="" className="w-12 h-12 rounded-full object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded-full bg-muted shrink-0"/>)} {artist.images ? (<img src={artist.images} alt="" className="w-12 h-12 rounded-full object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded-full bg-muted shrink-0"/>)}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium truncate">{artist.name}</p> <p className="font-medium truncate">{artist.name}</p>
<p className="text-sm text-muted-foreground">Artist</p> <p className="text-sm text-muted-foreground">Artist</p>
</div> </div>
</button>))} </button>))}
{activeTab === "playlists" &&
{activeTab === "playlists" && searchResults?.playlists.map((playlist) => (<button key={playlist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(playlist.external_urls)}> searchResults?.playlists.map((playlist) => (<button key={playlist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(playlist.external_urls)}>
{playlist.images ? (<img src={playlist.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)} {playlist.images ? (<img src={playlist.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium truncate">{playlist.name}</p> <p className="font-medium truncate">{playlist.name}</p>
<p className="text-sm text-muted-foreground truncate"> <p className="text-sm text-muted-foreground truncate">
{playlist.owner || ""} {playlist.owner || ""}
</p> </p>
</div> </div>
</button>))} </button>))}
</div> </div>
{hasMore[activeTab] && (<div className="flex justify-center pt-2"> {hasMore[activeTab] && (<div className="flex justify-center pt-2">
<Button variant="outline" onClick={handleLoadMore} disabled={isLoadingMore}> <Button variant="outline" onClick={handleLoadMore} disabled={isLoadingMore}>
{isLoadingMore ? (<> {isLoadingMore ? (<>
@@ -369,5 +631,36 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
</div>)} </div>)}
</>)} </>)}
</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>); </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) { export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
return (<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30"> return (<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
<div className="flex flex-col gap-2 flex-1"> <div className="flex flex-col gap-2 flex-1">
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant={currentPage === "main" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "main" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("main")}>
<HomeIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Home</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant={currentPage === "main" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "main" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("main")}> <Button variant={currentPage === "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")}>
<HomeIcon size={20}/> <HistoryIcon size={20}/>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>Home</p> <p>History</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant={currentPage === "history" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "history" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("history")}> <Button variant={currentPage === "audio-analysis" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-analysis" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-analysis")}>
<HistoryIcon size={20}/> <ActivityIcon size={20}/>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>Download History</p> <p>Audio Quality Analyzer</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant={currentPage === "audio-analysis" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-analysis" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-analysis")}> <Button variant={currentPage === "audio-converter" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-converter" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-converter")}>
<ActivityIcon size={20}/> <FileMusicIcon size={20}/>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>Audio Quality Analyzer</p> <p>Audio Converter</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant={currentPage === "audio-converter" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-converter" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-converter")}> <Button variant={currentPage === "file-manager" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "file-manager" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("file-manager")}>
<FileMusicIcon size={20}/> <FilePenIcon size={20}/>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>Audio Converter</p> <p>File Manager</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant={currentPage === "file-manager" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "file-manager" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("file-manager")}> <Button variant={currentPage === "debug" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "debug" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("debug")}>
<FilePenIcon size={20}/> <TerminalIcon size={20} loop={true}/>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>File Manager</p> <p>Debug Logs</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <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")}> <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")}>
<TerminalIcon size={20} loop={true}/> <SettingsIcon size={20}/>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>Debug Logs</p> <p>Settings</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div>
<Tooltip delayDuration={0}> <div className="mt-auto flex flex-col gap-2">
<TooltipTrigger asChild> <Tooltip delayDuration={0}>
<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")}> <TooltipTrigger asChild>
<SettingsIcon size={20}/> <Button variant={currentPage === "about" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "about" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("about")}>
</Button> <BadgeAlertIcon size={20}/>
</TooltipTrigger> </Button>
<TooltipContent side="right"> </TooltipTrigger>
<p>Settings</p> <TooltipContent side="right">
</TooltipContent> <p>About</p>
</Tooltip> </TooltipContent>
</div> </Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<div className="mt-auto flex flex-col gap-2"> <Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
<Tooltip delayDuration={0}> <CoffeeIcon size={20} loop={true}/>
<TooltipTrigger asChild> </Button>
<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")}> </TooltipTrigger>
<BadgeAlertIcon size={20}/> <TooltipContent side="right">
</Button> <p>Support me on Ko-fi</p>
</TooltipTrigger> </TooltipContent>
<TooltipContent side="right"> </Tooltip>
<p>About</p> </div>
</TooltipContent> </div>);
</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>);
} }
+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 { 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() { 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 = () => { const handleMinimize = () => {
WindowMinimise(); WindowMinimise();
}; };
@@ -11,11 +35,39 @@ export function TitleBar() {
Quit(); Quit();
}; };
return (<> 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-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"> <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"/> <Minus className="w-3.5 h-3.5"/>
</button> </button>
+17 -9
View File
@@ -4,7 +4,7 @@ import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe,
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import type { TrackMetadata, TrackAvailability } from "@/types/api"; import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons"; import { TidalIcon, QobuzIcon, AmazonIcon, DeezerIcon } from "./PlatformIcons";
import { usePreview } from "@/hooks/usePreview"; import { usePreview } from "@/hooks/usePreview";
interface TrackInfoProps { interface TrackInfoProps {
track: TrackMetadata & { track: TrackMetadata & {
@@ -26,13 +26,14 @@ interface TrackInfoProps {
downloadedCover?: boolean; downloadedCover?: boolean;
failedCover?: boolean; failedCover?: boolean;
skippedCover?: 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; 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; onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onOpenFolder: () => void; onOpenFolder: () => void;
onBack?: () => void;
} }
export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, }: TrackInfoProps) { export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, onBack, }: TrackInfoProps) {
const { playPreview, loadingPreview, playingTrack } = usePreview(); const { playPreview, loadingPreview, playingTrack } = usePreview();
const formatDuration = (ms: number) => { const formatDuration = (ms: number) => {
const minutes = Math.floor(ms / 60000); const minutes = Math.floor(ms / 60000);
@@ -45,7 +46,12 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
return plays; return plays;
return num.toLocaleString(); return num.toLocaleString();
}; };
return (<Card> return (<Card className="relative">
{onBack && (<div className="absolute top-4 right-4 z-10">
<Button variant="ghost" size="icon" onClick={onBack}>
<XCircle className="h-5 w-5"/>
</Button>
</div>)}
<CardContent className="px-6"> <CardContent className="px-6">
<div className="flex gap-6 items-start"> <div className="flex gap-6 items-start">
<div className="shrink-0"> <div className="shrink-0">
@@ -60,6 +66,7 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1> <h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
{track.is_explicit && (<span className="inline-flex items-center justify-center bg-red-600 text-white text-[10px] h-4 w-4 rounded shrink-0" title="Explicit">E</span>)}
{isSkipped ? (<FileCheck className="h-6 w-6 text-yellow-500 shrink-0"/>) : isDownloaded ? (<CheckCircle className="h-6 w-6 text-green-500 shrink-0"/>) : isFailed ? (<XCircle className="h-6 w-6 text-red-500 shrink-0"/>) : null} {isSkipped ? (<FileCheck className="h-6 w-6 text-yellow-500 shrink-0"/>) : isDownloaded ? (<CheckCircle className="h-6 w-6 text-green-500 shrink-0"/>) : isFailed ? (<XCircle className="h-6 w-6 text-red-500 shrink-0"/>) : null}
</div> </div>
<p className="text-lg text-muted-foreground">{track.artists}</p> <p className="text-lg text-muted-foreground">{track.artists}</p>
@@ -88,9 +95,9 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
</div>)} </div>)}
</div> </div>
</div> </div>
{track.isrc && (<div className="flex gap-2 flex-wrap"> {track.spotify_id && (<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}> <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.isrc ? (<Spinner />) : (<> {downloadingTrack === track.spotify_id ? (<Spinner />) : (<>
<Download className="h-4 w-4"/> <Download className="h-4 w-4"/>
Download Download
</>)} </>)}
@@ -127,7 +134,7 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
</Tooltip>)} </Tooltip>)}
{track.spotify_id && onCheckAvailability && (<Tooltip> {track.spotify_id && onCheckAvailability && (<Tooltip>
<TooltipTrigger asChild> <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"/>)} {checkingAvailability ? (<Spinner />) : availability ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
</Button> </Button>
</TooltipTrigger> </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"}`}/> <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"}`}/> <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"}`}/> <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>)} </div>) : (<p>Check Availability</p>)}
</TooltipContent> </TooltipContent>
</Tooltip>)} </Tooltip>)}
+28 -18
View File
@@ -5,7 +5,7 @@ import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination"; import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
import type { TrackMetadata, TrackAvailability } from "@/types/api"; import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons"; import { TidalIcon, QobuzIcon, AmazonIcon, DeezerIcon } from "./PlatformIcons";
import { usePreview } from "@/hooks/usePreview"; import { usePreview } from "@/hooks/usePreview";
interface TrackListProps { interface TrackListProps {
tracks: TrackMetadata[]; tracks: TrackMetadata[];
@@ -33,11 +33,11 @@ interface TrackListProps {
failedCovers?: Set<string>; failedCovers?: Set<string>;
skippedCovers?: Set<string>; skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null; downloadingCoverTrack?: string | null;
onToggleTrack: (isrc: string) => void; onToggleTrack: (id: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, 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; 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; onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
onAlbumClick?: (album: { onAlbumClick?: (album: {
@@ -104,18 +104,25 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
} }
else if (sortBy === "downloaded") { else if (sortBy === "downloaded") {
filteredTracks = [...filteredTracks].sort((a, b) => { filteredTracks = [...filteredTracks].sort((a, b) => {
const aDownloaded = downloadedTracks.has(a.isrc); const aDownloaded = a.spotify_id ? downloadedTracks.has(a.spotify_id) : false;
const bDownloaded = downloadedTracks.has(b.isrc); const bDownloaded = b.spotify_id ? downloadedTracks.has(b.spotify_id) : false;
return (bDownloaded ? 1 : 0) - (aDownloaded ? 1 : 0); return (bDownloaded ? 1 : 0) - (aDownloaded ? 1 : 0);
}); });
} }
else if (sortBy === "not-downloaded") { else if (sortBy === "not-downloaded") {
filteredTracks = [...filteredTracks].sort((a, b) => { filteredTracks = [...filteredTracks].sort((a, b) => {
const aDownloaded = downloadedTracks.has(a.isrc); const aDownloaded = a.spotify_id ? downloadedTracks.has(a.spotify_id) : false;
const bDownloaded = downloadedTracks.has(b.isrc); const bDownloaded = b.spotify_id ? downloadedTracks.has(b.spotify_id) : false;
return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0); 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 totalPages = Math.ceil(filteredTracks.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage; const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage; const endIndex = startIndex + itemsPerPage;
@@ -149,9 +156,9 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
} }
return pages; return pages;
}; };
const tracksWithIsrc = filteredTracks.filter((track) => track.isrc); const tracksWithId = filteredTracks.filter((track) => track.spotify_id);
const allSelected = tracksWithIsrc.length > 0 && const allSelected = tracksWithId.length > 0 &&
tracksWithIsrc.every((track) => selectedTracks.includes(track.isrc)); tracksWithId.every((track) => selectedTracks.includes(track.spotify_id!));
const formatDuration = (ms: number) => { const formatDuration = (ms: number) => {
const minutes = Math.floor(ms / 60000); const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000); const seconds = Math.floor((ms % 60000) / 1000);
@@ -197,7 +204,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
<tbody> <tbody>
{paginatedTracks.map((track, index) => (<tr key={index} className="border-b transition-colors hover:bg-muted/50"> {paginatedTracks.map((track, index) => (<tr key={index} className="border-b transition-colors hover:bg-muted/50">
{showCheckboxes && (<td className="p-4 align-middle"> {showCheckboxes && (<td className="p-4 align-middle">
{track.isrc && (<Checkbox checked={selectedTracks.includes(track.isrc)} onCheckedChange={() => onToggleTrack(track.isrc)}/>)} {track.spotify_id && (<Checkbox checked={selectedTracks.includes(track.spotify_id)} onCheckedChange={() => onToggleTrack(track.spotify_id!)}/>)}
</td>)} </td>)}
<td className="p-4 align-middle text-sm text-muted-foreground"> <td className="p-4 align-middle text-sm text-muted-foreground">
<div className="flex flex-col items-center gap-0.5"> <div className="flex flex-col items-center gap-0.5">
@@ -221,7 +228,9 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
{onTrackClick ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onTrackClick(track)}> {onTrackClick ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onTrackClick(track)}>
{track.name} {track.name}
</span>) : (<span className="font-medium">{track.name}</span>)} </span>) : (<span className="font-medium">{track.name}</span>)}
{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> </div>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{track.artists_data && track.artists_data.length > 0 ? ((() => { {track.artists_data && track.artists_data.length > 0 ? ((() => {
@@ -268,14 +277,14 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
</td> </td>
<td className="p-4 align-middle text-center"> <td className="p-4 align-middle text-center">
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1">
{track.isrc && (<Tooltip> {track.spotify_id && (<Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button onClick={() => onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} size="icon" disabled={isDownloading || downloadingTrack === track.isrc}> <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.isrc ? (<Spinner />) : skippedTracks.has(track.isrc) ? (<FileCheck className="h-4 w-4"/>) : downloadedTracks.has(track.isrc) ? (<CheckCircle className="h-4 w-4"/>) : failedTracks.has(track.isrc) ? (<XCircle className="h-4 w-4"/>) : (<Download className="h-4 w-4"/>)} {downloadingTrack === track.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> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{downloadingTrack === track.isrc ? (<p>Downloading...</p>) : skippedTracks.has(track.isrc) ? (<p>Already exists</p>) : downloadedTracks.has(track.isrc) ? (<p>Downloaded</p>) : failedTracks.has(track.isrc) ? (<p>Failed</p>) : (<p>Download Track</p>)} {downloadingTrack === track.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> </TooltipContent>
</Tooltip>)} </Tooltip>)}
{track.spotify_id && (<Tooltip> {track.spotify_id && (<Tooltip>
@@ -313,7 +322,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
</Tooltip>)} </Tooltip>)}
{track.spotify_id && onCheckAvailability && (<Tooltip> {track.spotify_id && onCheckAvailability && (<Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} size="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"/>)} {checkingAvailabilityTrack === track.spotify_id ? (<Spinner />) : availabilityMap?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
@@ -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"}`}/> <TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`}/>
<QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`}/> <QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`}/>
<AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`}/> <AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`}/>
<DeezerIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.deezer ? "text-green-500" : "text-red-500"}`}/>
</div>) : (<p>Check Availability</p>)} </div>) : (<p>Check Availability</p>)}
</TooltipContent> </TooltipContent>
</Tooltip>)} </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 [checkingTrackId, setCheckingTrackId] = useState<string | null>(null);
const [availabilityMap, setAvailabilityMap] = useState<Map<string, TrackAvailability>>(new Map()); const [availabilityMap, setAvailabilityMap] = useState<Map<string, TrackAvailability>>(new Map());
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const checkAvailability = useCallback(async (spotifyId: string, isrc?: string) => { const checkAvailability = useCallback(async (spotifyId: string) => {
if (!spotifyId) { if (!spotifyId) {
setError("No Spotify ID provided"); setError("No Spotify ID provided");
return null; return null;
@@ -20,7 +20,7 @@ export function useAvailability() {
setError(null); setError(null);
try { try {
logger.info(`Checking availability for track: ${spotifyId}`); logger.info(`Checking availability for track: ${spotifyId}`);
const response = await CheckTrackAvailability(spotifyId, isrc || ""); const response = await CheckTrackAvailability(spotifyId);
const availability: TrackAvailability = JSON.parse(response); const availability: TrackAvailability = JSON.parse(response);
setAvailabilityMap((prev) => { setAvailabilityMap((prev) => {
const newMap = new Map(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 { downloadCover } from "@/lib/api";
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings"; import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; 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 { logger } from "@/lib/logger";
import type { TrackMetadata } from "@/types/api"; import type { TrackMetadata } from "@/types/api";
export function useCover() { export function useCover() {
@@ -28,11 +28,17 @@ export function useCover() {
const os = settings.operatingSystem; const os = settings.operatingSystem;
let outputDir = settings.downloadPath; let outputDir = settings.downloadPath;
const placeholder = "__SLASH_PLACEHOLDER__"; 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 = { const templateData: TemplateData = {
artist: artistName?.replace(/\//g, placeholder), artist: displayArtist?.replace(/\//g, placeholder),
album: albumName?.replace(/\//g, placeholder), album: albumName?.replace(/\//g, placeholder),
album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
title: trackName?.replace(/\//g, placeholder), title: trackName?.replace(/\//g, placeholder),
track: position, track: position,
year: yearValue,
date: releaseDate,
playlist: playlistName?.replace(/\//g, placeholder), playlist: playlistName?.replace(/\//g, placeholder),
}; };
const folderTemplate = settings.folderTemplate || ""; const folderTemplate = settings.folderTemplate || "";
@@ -53,9 +59,9 @@ export function useCover() {
const response = await downloadCover({ const response = await downloadCover({
cover_url: coverUrl, cover_url: coverUrl,
track_name: trackName, track_name: trackName,
artist_name: artistName, artist_name: displayArtist,
album_name: albumName || "", album_name: albumName || "",
album_artist: albumArtist || "", album_artist: displayAlbumArtist || "",
release_date: releaseDate || "", release_date: releaseDate || "",
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}", filename_format: settings.filenameTemplate || "{title}",
@@ -124,11 +130,17 @@ export function useCover() {
const placeholder = "__SLASH_PLACEHOLDER__"; const placeholder = "__SLASH_PLACEHOLDER__";
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false; const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1); 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 = { const templateData: TemplateData = {
artist: track.artists?.replace(/\//g, placeholder), artist: displayArtist?.replace(/\//g, placeholder),
album: track.album_name?.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), title: track.name?.replace(/\//g, placeholder),
track: trackPosition, track: trackPosition,
year: yearValue,
date: track.release_date,
playlist: playlistName?.replace(/\//g, placeholder), playlist: playlistName?.replace(/\//g, placeholder),
}; };
const folderTemplate = settings.folderTemplate || ""; const folderTemplate = settings.folderTemplate || "";
@@ -149,9 +161,9 @@ export function useCover() {
const response = await downloadCover({ const response = await downloadCover({
cover_url: track.images, cover_url: track.images,
track_name: track.name, track_name: track.name,
artist_name: track.artists, artist_name: displayArtist,
album_name: track.album_name, album_name: track.album_name,
album_artist: track.album_artist, album_artist: displayAlbumArtist,
release_date: track.release_date, release_date: track.release_date,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}", 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 { downloadTrack, fetchSpotifyMetadata } from "@/lib/api";
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings"; import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; 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 { logger } from "@/lib/logger";
import type { TrackMetadata } from "@/types/api"; import type { TrackMetadata } from "@/types/api";
interface CheckFileExistenceRequest { interface CheckFileExistenceRequest {
@@ -19,6 +19,7 @@ interface CheckFileExistenceRequest {
filename_format?: string; filename_format?: string;
include_track_number?: boolean; include_track_number?: boolean;
audio_format?: string; audio_format?: string;
relative_path?: string;
} }
interface FileExistenceResult { interface FileExistenceResult {
spotify_id: string; spotify_id: string;
@@ -27,9 +28,10 @@ interface FileExistenceResult {
track_name?: string; track_name?: string;
artist_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); 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 [downloadProgress, setDownloadProgress] = useState<number>(0);
const [isDownloading, setIsDownloading] = useState(false); const [isDownloading, setIsDownloading] = useState(false);
const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null); const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null);
@@ -42,7 +44,7 @@ export function useDownload() {
artists: string; artists: string;
} | null>(null); } | null>(null);
const shouldStopDownloadRef = useRef(false); const shouldStopDownloadRef = useRef(false);
const downloadWithAutoFallback = async (isrc: string, settings: any, trackName?: string, artistName?: string, albumName?: string, playlistName?: string, position?: number, spotifyId?: string, durationMs?: number, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => { const downloadWithAutoFallback = async (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 service = settings.downloader;
const query = trackName && artistName ? `${trackName} ${artistName} ` : undefined; const query = trackName && artistName ? `${trackName} ${artistName} ` : undefined;
const os = settings.operatingSystem; const os = settings.operatingSystem;
@@ -73,18 +75,25 @@ export function useDownload() {
if (hasSubfolder) { if (hasSubfolder) {
useAlbumTrackNumber = true; useAlbumTrackNumber = true;
} }
const displayArtist = settings.useFirstArtistOnly && artistName
? getFirstArtist(artistName)
: artistName;
const displayAlbumArtist = settings.useFirstArtistOnly && albumArtist
? getFirstArtist(albumArtist)
: albumArtist;
const templateData: TemplateData = { const templateData: TemplateData = {
artist: artistName?.replace(/\//g, placeholder), artist: displayArtist?.replace(/\//g, placeholder),
album: albumName?.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), title: trackName?.replace(/\//g, placeholder),
track: trackNumberForTemplate, track: trackNumberForTemplate,
year: yearValue, year: yearValue,
date: releaseDate,
playlist: playlistName?.replace(/\//g, placeholder), playlist: playlistName?.replace(/\//g, placeholder),
}; };
const folderTemplate = settings.folderTemplate || ""; const folderTemplate = settings.folderTemplate || "";
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}"); 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)); outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
} }
if (settings.folderTemplate) { if (settings.folderTemplate) {
@@ -102,11 +111,11 @@ export function useDownload() {
if (trackName && artistName) { if (trackName && artistName) {
try { try {
const checkRequest: CheckFileExistenceRequest = { const checkRequest: CheckFileExistenceRequest = {
spotify_id: spotifyId || isrc, spotify_id: spotifyId || id,
track_name: trackName, track_name: trackName,
artist_name: artistName, artist_name: displayArtist || "",
album_name: albumName, album_name: albumName,
album_artist: albumArtist, album_artist: displayAlbumArtist,
release_date: finalReleaseDate || releaseDate, release_date: finalReleaseDate || releaseDate,
track_number: finalTrackNumber || spotifyTrackNumber || 0, track_number: finalTrackNumber || spotifyTrackNumber || 0,
disc_number: spotifyDiscNumber || 0, disc_number: spotifyDiscNumber || 0,
@@ -116,7 +125,7 @@ export function useDownload() {
include_track_number: settings.trackNumber || false, include_track_number: settings.trackNumber || false,
audio_format: serviceForCheck, audio_format: serviceForCheck,
}; };
const existenceResults = await CheckFilesExistence(outputDir, [checkRequest]); const existenceResults = await CheckFilesExistence(outputDir, settings.downloadPath, [checkRequest]);
if (existenceResults.length > 0 && existenceResults[0].exists) { if (existenceResults.length > 0 && existenceResults[0].exists) {
fileExists = true; fileExists = true;
return { return {
@@ -134,14 +143,14 @@ export function useDownload() {
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App"); const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
let itemID: string | undefined; let itemID: string | undefined;
if (!fileExists) { if (!fileExists) {
itemID = await AddToDownloadQueue(isrc, trackName || "", artistName || "", albumName || ""); itemID = await AddToDownloadQueue(id, trackName || "", displayArtist || "", albumName || "");
} }
if (service === "auto") { if (service === "auto") {
let streamingURLs: any = null; let streamingURLs: any = null;
if (spotifyId) { if (spotifyId) {
try { try {
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App"); const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
const urlsJson = await GetStreamingURLs(spotifyId); const urlsJson = await GetStreamingURLs(spotifyId, region);
streamingURLs = JSON.parse(urlsJson); streamingURLs = JSON.parse(urlsJson);
} }
catch (err) { catch (err) {
@@ -151,21 +160,21 @@ export function useDownload() {
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined; const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-"); const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
let lastResponse: any = { success: false, error: "No matching services found" }; let lastResponse: any = { success: false, error: "No matching services found" };
const fallbackErrors: string[] = [];
const is24Bit = (settings.autoQuality || "24") === "24"; const is24Bit = (settings.autoQuality || "24") === "24";
const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS"; const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
const qobuzQuality = is24Bit ? "7" : "6"; const qobuzQuality = is24Bit ? "27" : "6";
for (const s of order) { for (const s of order) {
if (s === "tidal" && streamingURLs?.tidal_url) { if (s === "tidal" && streamingURLs?.tidal_url) {
try { try {
logger.debug(`trying tidal for: ${trackName} - ${artistName}`); logger.debug(`trying tidal for: ${trackName} - ${artistName}`);
const response = await downloadTrack({ const response = await downloadTrack({
isrc,
service: "tidal", service: "tidal",
query, query,
track_name: trackName, track_name: trackName,
artist_name: artistName, artist_name: displayArtist,
album_name: albumName, album_name: albumName,
album_artist: albumArtist, album_artist: displayAlbumArtist,
release_date: finalReleaseDate || releaseDate, release_date: finalReleaseDate || releaseDate,
cover_url: coverUrl, cover_url: coverUrl,
output_dir: outputDir, output_dir: outputDir,
@@ -186,16 +195,22 @@ export function useDownload() {
spotify_total_discs: spotifyTotalDiscs, spotify_total_discs: spotifyTotalDiscs,
copyright: copyright, copyright: copyright,
publisher: publisher, publisher: publisher,
use_first_artist_only: settings.useFirstArtistOnly,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
}); });
if (response.success) { if (response.success) {
logger.success(`tidal: ${trackName} - ${artistName}`); logger.success(`tidal: ${trackName} - ${artistName}`);
return response; return response;
} }
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Tidal] ${errMsg}`);
lastResponse = response; lastResponse = response;
logger.warning(`tidal failed, trying next...`); logger.warning(`tidal failed, trying next...`);
} }
catch (err) { catch (err) {
logger.error(`tidal error: ${err}`); logger.error(`tidal error: ${err}`);
fallbackErrors.push(`[Tidal] ${String(err)}`);
lastResponse = { success: false, error: String(err) }; lastResponse = { success: false, error: String(err) };
} }
} }
@@ -203,13 +218,12 @@ export function useDownload() {
try { try {
logger.debug(`trying amazon for: ${trackName} - ${artistName}`); logger.debug(`trying amazon for: ${trackName} - ${artistName}`);
const response = await downloadTrack({ const response = await downloadTrack({
isrc,
service: "amazon", service: "amazon",
query, query,
track_name: trackName, track_name: trackName,
artist_name: artistName, artist_name: displayArtist,
album_name: albumName, album_name: albumName,
album_artist: albumArtist, album_artist: displayAlbumArtist,
release_date: finalReleaseDate || releaseDate, release_date: finalReleaseDate || releaseDate,
cover_url: coverUrl, cover_url: coverUrl,
output_dir: outputDir, output_dir: outputDir,
@@ -228,16 +242,21 @@ export function useDownload() {
spotify_total_discs: spotifyTotalDiscs, spotify_total_discs: spotifyTotalDiscs,
copyright: copyright, copyright: copyright,
publisher: publisher, publisher: publisher,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
}); });
if (response.success) { if (response.success) {
logger.success(`amazon: ${trackName} - ${artistName}`); logger.success(`amazon: ${trackName} - ${artistName}`);
return response; return response;
} }
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Amazon] ${errMsg}`);
lastResponse = response; lastResponse = response;
logger.warning(`amazon failed, trying next...`); logger.warning(`amazon failed, trying next...`);
} }
catch (err) { catch (err) {
logger.error(`amazon error: ${err}`); logger.error(`amazon error: ${err}`);
fallbackErrors.push(`[Amazon] ${String(err)}`);
lastResponse = { success: false, error: String(err) }; lastResponse = { success: false, error: String(err) };
} }
} }
@@ -245,13 +264,12 @@ export function useDownload() {
try { try {
logger.debug(`trying qobuz for: ${trackName} - ${artistName}`); logger.debug(`trying qobuz for: ${trackName} - ${artistName}`);
const response = await downloadTrack({ const response = await downloadTrack({
isrc,
service: "qobuz", service: "qobuz",
query, query,
track_name: trackName, track_name: trackName,
artist_name: artistName, artist_name: displayArtist,
album_name: albumName, album_name: albumName,
album_artist: albumArtist, album_artist: displayAlbumArtist,
release_date: finalReleaseDate || releaseDate, release_date: finalReleaseDate || releaseDate,
cover_url: coverUrl, cover_url: coverUrl,
output_dir: outputDir, output_dir: outputDir,
@@ -270,23 +288,77 @@ export function useDownload() {
spotify_total_discs: spotifyTotalDiscs, spotify_total_discs: spotifyTotalDiscs,
copyright: copyright, copyright: copyright,
publisher: publisher, publisher: publisher,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
}); });
if (response.success) { if (response.success) {
logger.success(`qobuz: ${trackName} - ${artistName}`); logger.success(`qobuz: ${trackName} - ${artistName}`);
return response; return response;
} }
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Qobuz] ${errMsg}`);
lastResponse = response; lastResponse = response;
logger.warning(`qobuz failed, trying next...`); logger.warning(`qobuz failed, trying next...`);
} }
catch (err) { catch (err) {
logger.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,
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) }; lastResponse = { success: false, error: String(err) };
} }
} }
} }
if (itemID) { if (itemID) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); 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; return lastResponse;
} }
@@ -298,14 +370,17 @@ export function useDownload() {
else if (service === "qobuz") { else if (service === "qobuz") {
audioFormat = settings.qobuzQuality || "6"; audioFormat = settings.qobuzQuality || "6";
} }
else if (service === "deezer") {
audioFormat = "flac";
}
logger.debug(`trying ${service} for: ${trackName} - ${artistName}`);
const singleServiceResponse = await downloadTrack({ const singleServiceResponse = await downloadTrack({
isrc, service: service as "tidal" | "qobuz" | "amazon" | "deezer",
service: service as "tidal" | "qobuz" | "amazon",
query, query,
track_name: trackName, track_name: trackName,
artist_name: artistName, artist_name: displayArtist,
album_name: albumName, album_name: albumName,
album_artist: albumArtist, album_artist: displayAlbumArtist,
release_date: finalReleaseDate || releaseDate, release_date: finalReleaseDate || releaseDate,
cover_url: coverUrl, cover_url: coverUrl,
output_dir: outputDir, output_dir: outputDir,
@@ -325,6 +400,8 @@ export function useDownload() {
spotify_total_discs: spotifyTotalDiscs, spotify_total_discs: spotifyTotalDiscs,
copyright: copyright, copyright: copyright,
publisher: publisher, publisher: publisher,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
}); });
if (!singleServiceResponse.success && itemID) { if (!singleServiceResponse.success && itemID) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
@@ -332,7 +409,7 @@ export function useDownload() {
} }
return singleServiceResponse; 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 service = settings.downloader;
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined; const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
const os = settings.operatingSystem; const os = settings.operatingSystem;
@@ -360,20 +437,25 @@ export function useDownload() {
const yearValue = releaseYear || finalReleaseDate?.substring(0, 4); const yearValue = releaseYear || finalReleaseDate?.substring(0, 4);
const hasSubfolder = settings.folderTemplate && settings.folderTemplate.trim() !== ""; const hasSubfolder = settings.folderTemplate && settings.folderTemplate.trim() !== "";
const trackNumberForTemplate = (hasSubfolder && finalTrackNumber > 0) ? finalTrackNumber : (position || 0); const trackNumberForTemplate = (hasSubfolder && finalTrackNumber > 0) ? finalTrackNumber : (position || 0);
if (hasSubfolder) { const displayArtist = settings.useFirstArtistOnly && artistName
useAlbumTrackNumber = true; ? getFirstArtist(artistName)
} : artistName;
const displayAlbumArtist = settings.useFirstArtistOnly && albumArtist
? getFirstArtist(albumArtist)
: albumArtist;
const templateData: TemplateData = { const templateData: TemplateData = {
artist: artistName?.replace(/\//g, placeholder), artist: displayArtist?.replace(/\//g, placeholder),
album: albumName?.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), title: trackName?.replace(/\//g, placeholder),
track: trackNumberForTemplate, track: trackNumberForTemplate,
year: yearValue, year: yearValue,
date: releaseDate,
playlist: folderName?.replace(/\//g, placeholder), playlist: folderName?.replace(/\//g, placeholder),
}; };
const useAlbumTag = settings.folderTemplate?.includes("{album}"); const folderTemplate = settings.folderTemplate || "";
if (folderName && (!isAlbum || !useAlbumTag)) { const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
if (settings.createPlaylistFolder && folderName && (!isAlbum || !useAlbumSubfolder)) {
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os)); outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
} }
if (settings.folderTemplate) { if (settings.folderTemplate) {
@@ -391,7 +473,7 @@ export function useDownload() {
if (spotifyId) { if (spotifyId) {
try { try {
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App"); const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
const urlsJson = await GetStreamingURLs(spotifyId); const urlsJson = await GetStreamingURLs(spotifyId, region);
streamingURLs = JSON.parse(urlsJson); streamingURLs = JSON.parse(urlsJson);
} }
catch (err) { catch (err) {
@@ -401,20 +483,21 @@ export function useDownload() {
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined; const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-"); const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
let lastResponse: any = { success: false, error: "No matching services found" }; let lastResponse: any = { success: false, error: "No matching services found" };
const fallbackErrors: string[] = [];
const is24Bit = (settings.autoQuality || "24") === "24"; const is24Bit = (settings.autoQuality || "24") === "24";
const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS"; const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
const qobuzQuality = is24Bit ? "7" : "6"; const qobuzQuality = is24Bit ? "27" : "6";
for (const s of order) { for (const s of order) {
if (s === "tidal" && streamingURLs?.tidal_url) { if (s === "tidal" && streamingURLs?.tidal_url) {
try { try {
logger.debug(`trying tidal for: ${trackName} - ${artistName}`);
const response = await downloadTrack({ const response = await downloadTrack({
isrc,
service: "tidal", service: "tidal",
query, query,
track_name: trackName, track_name: trackName,
artist_name: artistName, artist_name: displayArtist,
album_name: albumName, album_name: albumName,
album_artist: albumArtist, album_artist: displayAlbumArtist,
release_date: finalReleaseDate || releaseDate, release_date: finalReleaseDate || releaseDate,
cover_url: coverUrl, cover_url: coverUrl,
output_dir: outputDir, output_dir: outputDir,
@@ -435,27 +518,35 @@ export function useDownload() {
spotify_total_discs: spotifyTotalDiscs, spotify_total_discs: spotifyTotalDiscs,
copyright: copyright, copyright: copyright,
publisher: publisher, publisher: publisher,
use_first_artist_only: settings.useFirstArtistOnly,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
}); });
if (response.success) { if (response.success) {
logger.success(`tidal: ${trackName} - ${artistName}`);
return response; return response;
} }
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Tidal] ${errMsg}`);
lastResponse = response; lastResponse = response;
logger.warning(`tidal failed, trying next...`);
} }
catch (err) { catch (err) {
console.error("Tidal error:", err); logger.error(`tidal error: ${err}`);
fallbackErrors.push(`[Tidal] ${String(err)}`);
lastResponse = { success: false, error: String(err) }; lastResponse = { success: false, error: String(err) };
} }
} }
else if (s === "amazon" && streamingURLs?.amazon_url) { else if (s === "amazon" && streamingURLs?.amazon_url) {
try { try {
logger.debug(`trying amazon for: ${trackName} - ${artistName}`);
const response = await downloadTrack({ const response = await downloadTrack({
isrc,
service: "amazon", service: "amazon",
query, query,
track_name: trackName, track_name: trackName,
artist_name: artistName, artist_name: displayArtist,
album_name: albumName, album_name: albumName,
album_artist: albumArtist, album_artist: displayAlbumArtist,
release_date: finalReleaseDate || releaseDate, release_date: finalReleaseDate || releaseDate,
cover_url: coverUrl, cover_url: coverUrl,
output_dir: outputDir, output_dir: outputDir,
@@ -474,27 +565,35 @@ export function useDownload() {
spotify_total_discs: spotifyTotalDiscs, spotify_total_discs: spotifyTotalDiscs,
copyright: copyright, copyright: copyright,
publisher: publisher, publisher: publisher,
use_first_artist_only: settings.useFirstArtistOnly,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
}); });
if (response.success) { if (response.success) {
logger.success(`amazon: ${trackName} - ${artistName}`);
return response; return response;
} }
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Amazon] ${errMsg}`);
lastResponse = response; lastResponse = response;
logger.warning(`amazon failed, trying next...`);
} }
catch (err) { catch (err) {
console.error("Amazon error:", err); logger.error(`amazon error: ${err}`);
fallbackErrors.push(`[Amazon] ${String(err)}`);
lastResponse = { success: false, error: String(err) }; lastResponse = { success: false, error: String(err) };
} }
} }
else if (s === "qobuz") { else if (s === "qobuz") {
try { try {
logger.debug(`trying qobuz for: ${trackName} - ${artistName}`);
const response = await downloadTrack({ const response = await downloadTrack({
isrc,
service: "qobuz", service: "qobuz",
query, query,
track_name: trackName, track_name: trackName,
artist_name: artistName, artist_name: displayArtist,
album_name: albumName, album_name: albumName,
album_artist: albumArtist, album_artist: displayAlbumArtist,
release_date: finalReleaseDate || releaseDate, release_date: finalReleaseDate || releaseDate,
cover_url: coverUrl, cover_url: coverUrl,
output_dir: outputDir, output_dir: outputDir,
@@ -514,21 +613,78 @@ export function useDownload() {
spotify_total_discs: spotifyTotalDiscs, spotify_total_discs: spotifyTotalDiscs,
copyright: copyright, copyright: copyright,
publisher: publisher, publisher: publisher,
use_first_artist_only: settings.useFirstArtistOnly,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
}); });
if (response.success) { if (response.success) {
logger.success(`qobuz: ${trackName} - ${artistName}`);
return response; return response;
} }
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Qobuz] ${errMsg}`);
lastResponse = response; lastResponse = response;
logger.warning(`qobuz failed, trying next...`);
} }
catch (err) { 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) }; lastResponse = { success: false, error: String(err) };
} }
} }
} }
if (!lastResponse.success && itemID) { if (!lastResponse.success && itemID) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); 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; return lastResponse;
} }
@@ -540,14 +696,16 @@ export function useDownload() {
else if (service === "qobuz") { else if (service === "qobuz") {
audioFormat = settings.qobuzQuality || "6"; audioFormat = settings.qobuzQuality || "6";
} }
else if (service === "deezer") {
audioFormat = "flac";
}
const singleServiceResponse = await downloadTrack({ const singleServiceResponse = await downloadTrack({
isrc, service: service as "tidal" | "qobuz" | "amazon" | "deezer",
service: service as "tidal" | "qobuz" | "amazon",
query, query,
track_name: trackName, track_name: trackName,
artist_name: artistName, artist_name: displayArtist,
album_name: albumName, album_name: albumName,
album_artist: albumArtist, album_artist: displayAlbumArtist,
release_date: finalReleaseDate || releaseDate, release_date: finalReleaseDate || releaseDate,
cover_url: coverUrl, cover_url: coverUrl,
output_dir: outputDir, output_dir: outputDir,
@@ -567,6 +725,9 @@ export function useDownload() {
spotify_total_discs: spotifyTotalDiscs, spotify_total_discs: spotifyTotalDiscs,
copyright: copyright, copyright: copyright,
publisher: publisher, publisher: publisher,
use_first_artist_only: settings.useFirstArtistOnly,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
}); });
if (!singleServiceResponse.success && itemID) { if (!singleServiceResponse.success && itemID) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
@@ -574,40 +735,41 @@ export function useDownload() {
} }
return singleServiceResponse; 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) => { 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 (!isrc) { if (!id) {
toast.error("No ISRC found for this track"); toast.error("No ID found for this track");
return; return;
} }
logger.info(`starting download: ${trackName} - ${artistName}`);
const settings = getSettings(); const settings = getSettings();
setDownloadingTrack(isrc); const displayArtist = settings.useFirstArtistOnly && artistName ? getFirstArtist(artistName) : artistName;
logger.info(`starting download: ${trackName} - ${displayArtist}`);
setDownloadingTrack(id);
try { try {
const releaseYear = releaseDate?.substring(0, 4); 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.success) {
if (response.already_exists) { if (response.already_exists) {
toast.info(response.message); toast.info(response.message);
setSkippedTracks((prev) => new Set(prev).add(isrc)); setSkippedTracks((prev) => new Set(prev).add(id));
} }
else { else {
toast.success(response.message); toast.success(response.message);
} }
setDownloadedTracks((prev) => new Set(prev).add(isrc)); setDownloadedTracks((prev) => new Set(prev).add(id));
setFailedTracks((prev) => { setFailedTracks((prev) => {
const newSet = new Set(prev); const newSet = new Set(prev);
newSet.delete(isrc); newSet.delete(id);
return newSet; return newSet;
}); });
} }
else { else {
toast.error(response.error || "Download failed"); toast.error(response.error || "Download failed");
setFailedTracks((prev) => new Set(prev).add(isrc)); setFailedTracks((prev) => new Set(prev).add(id));
} }
} }
catch (err) { catch (err) {
toast.error(err instanceof Error ? err.message : "Download failed"); toast.error(err instanceof Error ? err.message : "Download failed");
setFailedTracks((prev) => new Set(prev).add(isrc)); setFailedTracks((prev) => new Set(prev).add(id));
} }
finally { finally {
setDownloadingTrack(null); setDownloadingTrack(null);
@@ -626,22 +788,24 @@ export function useDownload() {
let outputDir = settings.downloadPath; let outputDir = settings.downloadPath;
const os = settings.operatingSystem; const os = settings.operatingSystem;
const useAlbumTag = settings.folderTemplate?.includes("{album}"); 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)); outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
} }
const selectedTrackObjects = selectedTracks 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); .filter((t): t is TrackMetadata => t !== undefined);
logger.info(`checking existing files in parallel...`); logger.info(`checking existing files in parallel...`);
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false; const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
const audioFormat = "flac"; const audioFormat = "flac";
const existenceChecks = selectedTrackObjects.map((track, index) => { 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 { return {
spotify_id: track.spotify_id || track.isrc, spotify_id: track.spotify_id || "",
track_name: track.name || "", track_name: track.name || "",
artist_name: track.artists || "", artist_name: displayArtist || "",
album_name: track.album_name || "", album_name: track.album_name || "",
album_artist: track.album_artist || "", album_artist: displayAlbumArtist || "",
release_date: track.release_date || "", release_date: track.release_date || "",
track_number: track.track_number || 0, track_number: track.track_number || 0,
disc_number: track.disc_number || 0, disc_number: track.disc_number || 0,
@@ -652,32 +816,37 @@ export function useDownload() {
audio_format: audioFormat, audio_format: audioFormat,
}; };
}); });
const existenceResults = await CheckFilesExistence(outputDir, existenceChecks); const existenceResults = await CheckFilesExistence(outputDir, settings.downloadPath, existenceChecks);
const existingSpotifyIDs = new Set<string>(); const existingSpotifyIDs = new Set<string>();
const existingFilePaths = new Map<string, string>(); const existingFilePaths = new Map<string, string>();
const finalFilePaths = new Map<string, string>();
for (const result of existenceResults) { for (const result of existenceResults) {
if (result.exists) { if (result.exists) {
existingSpotifyIDs.add(result.spotify_id); existingSpotifyIDs.add(result.spotify_id);
existingFilePaths.set(result.spotify_id, result.file_path || ""); existingFilePaths.set(result.spotify_id, result.file_path || "");
finalFilePaths.set(result.spotify_id, result.file_path || "");
} }
} }
logger.info(`found ${existingSpotifyIDs.size} existing files`); logger.info(`found ${existingSpotifyIDs.size} existing files`);
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App"); const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
const itemIDs: string[] = []; const itemIDs: string[] = [];
for (const isrc of selectedTracks) { for (const id of selectedTracks) {
const track = allTracks.find((t) => t.isrc === isrc); const track = allTracks.find((t) => t.spotify_id === id);
const trackID = track?.spotify_id || isrc; if (!track)
const itemID = await AddToDownloadQueue(trackID, track?.name || "", track?.artists || "", track?.album_name || ""); 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); itemIDs.push(itemID);
if (existingSpotifyIDs.has(trackID)) { if (existingSpotifyIDs.has(trackID)) {
const filePath = existingFilePaths.get(trackID) || ""; const filePath = existingFilePaths.get(trackID) || "";
setTimeout(() => SkipDownloadItem(itemID, filePath), 10); setTimeout(() => SkipDownloadItem(itemID, filePath), 10);
setSkippedTracks((prev) => new Set(prev).add(isrc)); setSkippedTracks((prev) => new Set(prev).add(id));
setDownloadedTracks((prev) => new Set(prev).add(isrc)); setDownloadedTracks((prev) => new Set(prev).add(id));
} }
} }
const tracksToDownload = selectedTrackObjects.filter((track) => { const tracksToDownload = selectedTrackObjects.filter((track) => {
const trackID = track.spotify_id || track.isrc; const trackID = track.spotify_id || "";
return !existingSpotifyIDs.has(trackID); return !existingSpotifyIDs.has(trackID);
}); });
let successCount = 0; let successCount = 0;
@@ -691,41 +860,46 @@ export function useDownload() {
break; break;
} }
const track = tracksToDownload[i]; const track = tracksToDownload[i];
const isrc = track.isrc; const id = track.spotify_id || "";
const originalIndex = selectedTracks.indexOf(isrc); const originalIndex = selectedTracks.indexOf(id);
const itemID = itemIDs[originalIndex]; const itemID = itemIDs[originalIndex];
setDownloadingTrack(isrc); setDownloadingTrack(id);
setCurrentDownloadInfo({ name: track.name, artists: track.artists }); const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists;
setCurrentDownloadInfo({ name: track.name, artists: displayArtist || "" });
try { try {
const releaseYear = track.release_date?.substring(0, 4); 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.success) {
if (response.already_exists) { if (response.already_exists) {
skippedCount++; skippedCount++;
logger.info(`skipped: ${track.name} - ${track.artists} (already exists)`); logger.info(`skipped: ${track.name} - ${displayArtist} (already exists)`);
setSkippedTracks((prev) => new Set(prev).add(isrc)); setSkippedTracks((prev) => new Set(prev).add(id));
} }
else { else {
successCount++; 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) => { setFailedTracks((prev) => {
const newSet = new Set(prev); const newSet = new Set(prev);
newSet.delete(isrc); newSet.delete(id);
return newSet; return newSet;
}); });
} }
else { else {
errorCount++; errorCount++;
logger.error(`failed: ${track.name} - ${track.artists}`); logger.error(`failed: ${track.name} - ${displayArtist}`);
setFailedTracks((prev) => new Set(prev).add(isrc)); setFailedTracks((prev) => new Set(prev).add(id));
} }
} }
catch (err) { catch (err) {
errorCount++; errorCount++;
logger.error(`error: ${track.name} - ${err}`); logger.error(`error: ${track.name} - ${err}`);
setFailedTracks((prev) => new Set(prev).add(isrc)); setFailedTracks((prev) => new Set(prev).add(id));
if (itemID) { if (itemID) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err)); await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
@@ -741,6 +915,20 @@ export function useDownload() {
shouldStopDownloadRef.current = false; shouldStopDownloadRef.current = false;
const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App"); const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App");
await CancelAllQueuedItems(); 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`); logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`);
if (errorCount === 0 && skippedCount === 0) { if (errorCount === 0 && skippedCount === 0) {
toast.success(`Downloaded ${successCount} tracks successfully`); toast.success(`Downloaded ${successCount} tracks successfully`);
@@ -762,12 +950,12 @@ export function useDownload() {
} }
}; };
const handleDownloadAll = async (tracks: TrackMetadata[], folderName?: string, isAlbum?: boolean) => { const handleDownloadAll = async (tracks: TrackMetadata[], folderName?: string, isAlbum?: boolean) => {
const tracksWithIsrc = tracks.filter((track) => track.isrc); const tracksWithId = tracks.filter((track) => track.spotify_id);
if (tracksWithIsrc.length === 0) { if (tracksWithId.length === 0) {
toast.error("No tracks available for download"); toast.error("No tracks available for download");
return; return;
} }
logger.info(`starting batch download: ${tracksWithIsrc.length} tracks`); logger.info(`starting batch download: ${tracksWithId.length} tracks`);
const settings = getSettings(); const settings = getSettings();
setIsDownloading(true); setIsDownloading(true);
setBulkDownloadType("all"); setBulkDownloadType("all");
@@ -775,19 +963,21 @@ export function useDownload() {
let outputDir = settings.downloadPath; let outputDir = settings.downloadPath;
const os = settings.operatingSystem; const os = settings.operatingSystem;
const useAlbumTag = settings.folderTemplate?.includes("{album}"); 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)); outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
} }
logger.info(`checking existing files in parallel...`); logger.info(`checking existing files in parallel...`);
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false; const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
const audioFormat = "flac"; 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 { return {
spotify_id: track.spotify_id || track.isrc, spotify_id: track.spotify_id || "",
track_name: track.name || "", track_name: track.name || "",
artist_name: track.artists || "", artist_name: displayArtist || "",
album_name: track.album_name || "", album_name: track.album_name || "",
album_artist: track.album_artist || "", album_artist: displayAlbumArtist || "",
release_date: track.release_date || "", release_date: track.release_date || "",
track_number: track.track_number || 0, track_number: track.track_number || 0,
disc_number: track.disc_number || 0, disc_number: track.disc_number || 0,
@@ -798,37 +988,41 @@ export function useDownload() {
audio_format: audioFormat, 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 existingSpotifyIDs = new Set<string>();
const existingFilePaths = new Map<string, 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) { if (result.exists) {
existingSpotifyIDs.add(result.spotify_id); existingSpotifyIDs.add(result.spotify_id);
existingFilePaths.set(result.spotify_id, result.file_path || ""); existingFilePaths.set(result.spotify_id, result.file_path || "");
finalFilePaths[i] = result.file_path || "";
} }
} }
logger.info(`found ${existingSpotifyIDs.size} existing files`); logger.info(`found ${existingSpotifyIDs.size} existing files`);
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App"); const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
const itemIDs: string[] = []; const itemIDs: string[] = [];
for (const track of tracksWithIsrc) { for (const track of tracksWithId) {
const itemID = await AddToDownloadQueue(track.isrc, track.name, track.artists, track.album_name || ""); 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); itemIDs.push(itemID);
const trackID = track.spotify_id || track.isrc; const trackID = track.spotify_id || "";
if (existingSpotifyIDs.has(trackID)) { if (existingSpotifyIDs.has(trackID)) {
const filePath = existingFilePaths.get(trackID) || ""; const filePath = existingFilePaths.get(trackID) || "";
setTimeout(() => SkipDownloadItem(itemID, filePath), 10); setTimeout(() => SkipDownloadItem(itemID, filePath), 10);
setSkippedTracks((prev) => new Set(prev).add(track.isrc)); setSkippedTracks((prev: Set<string>) => new Set(prev).add(trackID));
setDownloadedTracks((prev) => new Set(prev).add(track.isrc)); setDownloadedTracks((prev: Set<string>) => new Set(prev).add(trackID));
} }
} }
const tracksToDownload = tracksWithIsrc.filter((track) => { const tracksToDownload = tracksWithId.filter((track) => {
const trackID = track.spotify_id || track.isrc; const trackID = track.spotify_id || "";
return !existingSpotifyIDs.has(trackID); return !existingSpotifyIDs.has(trackID);
}); });
let successCount = 0; let successCount = 0;
let errorCount = 0; let errorCount = 0;
let skippedCount = existingSpotifyIDs.size; let skippedCount = existingSpotifyIDs.size;
const total = tracksWithIsrc.length; const total = tracksWithId.length;
setDownloadProgress(Math.round((skippedCount / total) * 100)); setDownloadProgress(Math.round((skippedCount / total) * 100));
for (let i = 0; i < tracksToDownload.length; i++) { for (let i = 0; i < tracksToDownload.length; i++) {
if (shouldStopDownloadRef.current) { if (shouldStopDownloadRef.current) {
@@ -836,40 +1030,45 @@ export function useDownload() {
break; break;
} }
const track = tracksToDownload[i]; 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]; const itemID = itemIDs[originalIndex];
setDownloadingTrack(track.isrc); const trackId = track.spotify_id || "";
setCurrentDownloadInfo({ name: track.name, artists: track.artists }); setDownloadingTrack(trackId);
const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists;
setCurrentDownloadInfo({ name: track.name || "", artists: displayArtist || "" });
try { try {
const releaseYear = track.release_date?.substring(0, 4); 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.success) {
if (response.already_exists) { if (response.already_exists) {
skippedCount++; skippedCount++;
logger.info(`skipped: ${track.name} - ${track.artists} (already exists)`); logger.info(`skipped: ${track.name} - ${displayArtist} (already exists)`);
setSkippedTracks((prev) => new Set(prev).add(track.isrc)); setSkippedTracks((prev) => new Set(prev).add(trackId));
} }
else { else {
successCount++; 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) => { setFailedTracks((prev) => {
const newSet = new Set(prev); const newSet = new Set(prev);
newSet.delete(track.isrc); newSet.delete(trackId);
return newSet; return newSet;
}); });
if (response.file) {
finalFilePaths[originalIndex] = response.file;
}
} }
else { else {
errorCount++; errorCount++;
logger.error(`failed: ${track.name} - ${track.artists}`); logger.error(`failed: ${track.name} - ${displayArtist}`);
setFailedTracks((prev) => new Set(prev).add(track.isrc)); setFailedTracks((prev) => new Set(prev).add(trackId));
} }
} }
catch (err) { catch (err) {
errorCount++; errorCount++;
logger.error(`error: ${track.name} - ${err}`); 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"); const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err)); await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
} }
@@ -883,6 +1082,17 @@ export function useDownload() {
shouldStopDownloadRef.current = false; shouldStopDownloadRef.current = false;
const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App"); const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App");
await CancelQueued(); 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`); logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`);
if (errorCount === 0 && skippedCount === 0) { if (errorCount === 0 && skippedCount === 0) {
toast.success(`Downloaded ${successCount} tracks successfully`); 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 { downloadLyrics } from "@/lib/api";
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings"; import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; 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 { logger } from "@/lib/logger";
import type { TrackMetadata } from "@/types/api"; import type { TrackMetadata } from "@/types/api";
export function useLyrics() { export function useLyrics() {
@@ -25,11 +25,17 @@ export function useLyrics() {
const os = settings.operatingSystem; const os = settings.operatingSystem;
let outputDir = settings.downloadPath; let outputDir = settings.downloadPath;
const placeholder = "__SLASH_PLACEHOLDER__"; 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 = { const templateData: TemplateData = {
artist: artistName?.replace(/\//g, placeholder), artist: displayArtist?.replace(/\//g, placeholder),
album: albumName?.replace(/\//g, placeholder), album: albumName?.replace(/\//g, placeholder),
album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
title: trackName?.replace(/\//g, placeholder), title: trackName?.replace(/\//g, placeholder),
track: position, track: position,
year: yearValue,
date: releaseDate,
playlist: playlistName?.replace(/\//g, placeholder), playlist: playlistName?.replace(/\//g, placeholder),
}; };
const folderTemplate = settings.folderTemplate || ""; const folderTemplate = settings.folderTemplate || "";
@@ -51,9 +57,9 @@ export function useLyrics() {
const response = await downloadLyrics({ const response = await downloadLyrics({
spotify_id: spotifyId, spotify_id: spotifyId,
track_name: trackName, track_name: trackName,
artist_name: artistName, artist_name: displayArtist,
album_name: albumName, album_name: albumName,
album_artist: albumArtist, album_artist: displayAlbumArtist,
release_date: releaseDate, release_date: releaseDate,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}", filename_format: settings.filenameTemplate || "{title}",
@@ -120,11 +126,17 @@ export function useLyrics() {
const placeholder = "__SLASH_PLACEHOLDER__"; const placeholder = "__SLASH_PLACEHOLDER__";
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false; const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1); 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 = { const templateData: TemplateData = {
artist: track.artists?.replace(/\//g, placeholder), artist: displayArtist?.replace(/\//g, placeholder),
album: track.album_name?.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), title: track.name?.replace(/\//g, placeholder),
track: trackPosition, track: trackPosition,
year: yearValue,
date: track.release_date,
playlist: playlistName?.replace(/\//g, placeholder), playlist: playlistName?.replace(/\//g, placeholder),
}; };
const folderTemplate = settings.folderTemplate || ""; const folderTemplate = settings.folderTemplate || "";
@@ -145,9 +157,9 @@ export function useLyrics() {
const response = await downloadLyrics({ const response = await downloadLyrics({
spotify_id: id, spotify_id: id,
track_name: track.name, track_name: track.name,
artist_name: track.artists, artist_name: displayArtist,
album_name: track.album_name, album_name: track.album_name,
album_artist: track.album_artist, album_artist: displayAlbumArtist,
release_date: track.release_date, release_date: track.release_date,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}", filename_format: settings.filenameTemplate || "{title}",
+91 -44
View File
@@ -1,14 +1,14 @@
import { useState } from "react"; import { useState } from "react";
import { getSettings } from "@/lib/settings";
import { fetchSpotifyMetadata } from "@/lib/api"; import { fetchSpotifyMetadata } from "@/lib/api";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { AddFetchHistory } from "../../wailsjs/go/main/App";
import type { SpotifyMetadataResponse } from "@/types/api"; import type { SpotifyMetadataResponse } from "@/types/api";
export function useMetadata() { export function useMetadata() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [metadata, setMetadata] = useState<SpotifyMetadataResponse | null>(null); const [metadata, setMetadata] = useState<SpotifyMetadataResponse | null>(null);
const [showTimeoutDialog, setShowTimeoutDialog] = useState(false); const [showApiModal, setShowApiModal] = useState(false);
const [timeoutValue, setTimeoutValue] = useState(60);
const [pendingUrl, setPendingUrl] = useState("");
const [showAlbumDialog, setShowAlbumDialog] = useState(false); const [showAlbumDialog, setShowAlbumDialog] = useState(false);
const [selectedAlbum, setSelectedAlbum] = useState<{ const [selectedAlbum, setSelectedAlbum] = useState<{
id: string; id: string;
@@ -27,6 +27,57 @@ export function useMetadata() {
return "artist"; return "artist";
return "unknown"; return "unknown";
}; };
const saveToHistory = async (url: string, data: SpotifyMetadataResponse) => {
try {
let name = "";
let info = "";
let image = "";
let type = "unknown";
if ("track" in data) {
type = "track";
name = data.track.name;
info = data.track.artists;
image = (data.track.images && data.track.images.length > 0) ? data.track.images : "";
}
else if ("album_info" in data) {
type = "album";
name = data.album_info.name;
info = `${data.track_list.length} tracks`;
image = data.album_info.images;
}
else if ("playlist_info" in data) {
type = "playlist";
if (data.playlist_info.name) {
name = data.playlist_info.name;
}
else if (data.playlist_info.owner.name) {
name = data.playlist_info.owner.name;
}
info = `${data.playlist_info.tracks.total} tracks`;
image = data.playlist_info.cover || "";
}
else if ("artist_info" in data) {
type = "artist";
name = data.artist_info.name;
info = `${data.artist_info.total_albums || data.album_list.length} albums`;
image = data.artist_info.images;
}
const jsonStr = JSON.stringify(data);
await AddFetchHistory({
id: crypto.randomUUID(),
url: url,
type: type,
name: name,
info: info,
image: image,
data: jsonStr,
timestamp: Math.floor(Date.now() / 1000)
});
}
catch (err) {
console.error("Failed to save fetch history:", err);
}
};
const fetchMetadataDirectly = async (url: string) => { const fetchMetadataDirectly = async (url: string) => {
const urlType = getUrlType(url); const urlType = getUrlType(url);
logger.info(`fetching ${urlType} metadata...`); logger.info(`fetching ${urlType} metadata...`);
@@ -35,7 +86,8 @@ export function useMetadata() {
setMetadata(null); setMetadata(null);
try { try {
const startTime = Date.now(); const startTime = Date.now();
const data = await fetchSpotifyMetadata(url); const timeout = urlType === "artist" ? 60 : 300;
const data = await fetchSpotifyMetadata(url, true, 1.0, timeout);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
if ("playlist_info" in data) { if ("playlist_info" in data) {
const playlistInfo = data.playlist_info; const playlistInfo = data.playlist_info;
@@ -56,9 +108,10 @@ export function useMetadata() {
} }
} }
setMetadata(data); setMetadata(data);
saveToHistory(url, data);
if ("track" in data) { if ("track" in data) {
logger.success(`fetched track: ${data.track.name} - ${data.track.artists}`); logger.success(`fetched track: ${data.track.name} - ${data.track.artists}`);
logger.debug(`isrc: ${data.track.isrc}, duration: ${data.track.duration_ms}ms`); logger.debug(`duration: ${data.track.duration_ms}ms`);
} }
else if ("album_info" in data) { else if ("album_info" in data) {
logger.success(`fetched album: ${data.album_info.name}`); logger.success(`fetched album: ${data.album_info.name}`);
@@ -78,12 +131,29 @@ export function useMetadata() {
catch (err) { catch (err) {
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata"; const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
logger.error(`fetch failed: ${errorMsg}`); logger.error(`fetch failed: ${errorMsg}`);
toast.error(errorMsg); const settings = getSettings();
if (!settings.useSpotFetchAPI) {
setShowApiModal(true);
}
else {
toast.error(errorMsg);
}
} }
finally { finally {
setLoading(false); setLoading(false);
} }
}; };
const loadFromCache = (cachedData: string) => {
try {
const data = JSON.parse(cachedData);
setMetadata(data);
toast.success("Loaded from cache");
}
catch (err) {
console.error("Failed to load from cache:", err);
toast.error("Failed to load from cache");
}
};
const handleFetchMetadata = async (url: string) => { const handleFetchMetadata = async (url: string) => {
if (!url.trim()) { if (!url.trim()) {
logger.warning("empty url provided"); logger.warning("empty url provided");
@@ -97,43 +167,15 @@ export function useMetadata() {
logger.debug("converted to discography url"); logger.debug("converted to discography url");
} }
if (isArtistUrl) { if (isArtistUrl) {
logger.info("artist url detected, showing timeout dialog"); logger.info("artist url detected");
setPendingUrl(urlToFetch);
setPendingArtistName(null); setPendingArtistName(null);
setShowTimeoutDialog(true); await fetchMetadataDirectly(urlToFetch);
} }
else { else {
await fetchMetadataDirectly(urlToFetch); await fetchMetadataDirectly(urlToFetch);
} }
return urlToFetch; return urlToFetch;
}; };
const handleConfirmFetch = async () => {
setShowTimeoutDialog(false);
logger.info(`fetching artist discography (timeout: ${timeoutValue}s)...`);
logger.debug(`url: ${pendingUrl}`);
setLoading(true);
setMetadata(null);
try {
const startTime = Date.now();
const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
setMetadata(data);
if ("artist_info" in data) {
logger.success(`fetched artist: ${data.artist_info.name}`);
logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`);
}
logger.info(`fetch completed in ${elapsed}s`);
toast.success("Metadata fetched successfully");
}
catch (err) {
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
logger.error(`fetch failed: ${errorMsg}`);
toast.error(errorMsg);
}
finally {
setLoading(false);
}
};
const handleAlbumClick = (album: { const handleAlbumClick = (album: {
id: string; id: string;
name: string; name: string;
@@ -150,9 +192,8 @@ export function useMetadata() {
}) => { }) => {
logger.debug(`artist clicked: ${artist.name}`); logger.debug(`artist clicked: ${artist.name}`);
const artistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all"; const artistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
setPendingUrl(artistUrl);
setPendingArtistName(artist.name); setPendingArtistName(artist.name);
setShowTimeoutDialog(true); await fetchMetadataDirectly(artistUrl);
return artistUrl; return artistUrl;
}; };
const handleConfirmAlbumFetch = async () => { const handleConfirmAlbumFetch = async () => {
@@ -179,6 +220,7 @@ export function useMetadata() {
} }
} }
setMetadata(data); setMetadata(data);
saveToHistory(albumUrl, data);
if ("album_info" in data) { if ("album_info" in data) {
logger.success(`fetched album: ${data.album_info.name}`); logger.success(`fetched album: ${data.album_info.name}`);
logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`); logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`);
@@ -190,7 +232,13 @@ export function useMetadata() {
catch (err) { catch (err) {
const errorMsg = err instanceof Error ? err.message : "Failed to fetch album metadata"; const errorMsg = err instanceof Error ? err.message : "Failed to fetch album metadata";
logger.error(`fetch failed: ${errorMsg}`); logger.error(`fetch failed: ${errorMsg}`);
toast.error(errorMsg); const settings = getSettings();
if (!settings.useSpotFetchAPI) {
setShowApiModal(true);
}
else {
toast.error(errorMsg);
}
} }
finally { finally {
setLoading(false); setLoading(false);
@@ -200,18 +248,17 @@ export function useMetadata() {
return { return {
loading, loading,
metadata, metadata,
showTimeoutDialog,
setShowTimeoutDialog,
timeoutValue,
setTimeoutValue,
showAlbumDialog, showAlbumDialog,
setShowAlbumDialog, setShowAlbumDialog,
selectedAlbum, selectedAlbum,
pendingArtistName, pendingArtistName,
handleFetchMetadata, handleFetchMetadata,
handleConfirmFetch,
handleAlbumClick, handleAlbumClick,
handleConfirmAlbumFetch, handleConfirmAlbumFetch,
handleArtistClick, handleArtistClick,
loadFromCache,
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 { GetPreviewURL } from "@/../wailsjs/go/main/App";
import { toast } from "sonner"; import { toast } from "sonner";
export function usePreview() { export function usePreview() {
const [loadingPreview, setLoadingPreview] = useState<string | null>(null); const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
const [currentAudio, setCurrentAudio] = useState<HTMLAudioElement | null>(null); const [currentAudio, setCurrentAudio] = useState<HTMLAudioElement | null>(null);
const [playingTrack, setPlayingTrack] = useState<string | null>(null); const [playingTrack, setPlayingTrack] = useState<string | null>(null);
useEffect(() => {
return () => {
if (currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
}
};
}, [currentAudio]);
const playPreview = async (trackId: string, trackName: string) => { const playPreview = async (trackId: string, trackName: string) => {
try { try {
if (playingTrack === trackId && currentAudio) { if (playingTrack === trackId && currentAudio) {
+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> { export async function downloadTrack(request: DownloadRequest): Promise<DownloadResponse> {
const req = new main.DownloadRequest(request); 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); return await DownloadTrack(req);
} }
export async function checkHealth(): Promise<HealthResponse> { 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 type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "title-album-artist" | "track-title-album-artist" | "artist-album-title" | "track-dash-title" | "disc-track-title" | "disc-track-title-artist" | "custom";
export interface Settings { export interface Settings {
downloadPath: string; downloadPath: string;
downloader: "auto" | "tidal" | "qobuz" | "amazon"; downloader: "auto" | "tidal" | "qobuz" | "amazon" | "deezer";
theme: string; theme: string;
themeMode: "auto" | "light" | "dark"; themeMode: "auto" | "light" | "dark";
fontFamily: FontFamily; fontFamily: FontFamily;
@@ -21,10 +21,18 @@ export interface Settings {
embedMaxQualityCover: boolean; embedMaxQualityCover: boolean;
operatingSystem: "Windows" | "linux/MacOS"; operatingSystem: "Windows" | "linux/MacOS";
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS"; tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
qobuzQuality: "6" | "7"; qobuzQuality: "6" | "7" | "27";
amazonQuality: "original"; amazonQuality: "original";
autoOrder: "tidal-qobuz-amazon" | "tidal-amazon-qobuz" | "qobuz-tidal-amazon" | "qobuz-amazon-tidal" | "amazon-tidal-qobuz" | "amazon-qobuz-tidal" | "tidal-qobuz" | "tidal-amazon" | "qobuz-tidal" | "qobuz-amazon" | "amazon-tidal" | "amazon-qobuz"; autoOrder: "tidal-qobuz-amazon-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"; 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, { export const FOLDER_PRESETS: Record<FolderPreset, {
label: string; label: string;
@@ -72,6 +80,7 @@ export const TEMPLATE_VARIABLES = [
{ key: "{track}", description: "Track number", example: "01" }, { key: "{track}", description: "Track number", example: "01" },
{ key: "{disc}", description: "Disc number", example: "1" }, { key: "{disc}", description: "Disc number", example: "1" },
{ key: "{year}", description: "Release year", example: "2014" }, { key: "{year}", description: "Release year", example: "2014" },
{ key: "{date}", description: "Release date (YYYY-MM-DD)", example: "2014-10-27" },
]; ];
function detectOS(): "Windows" | "linux/MacOS" { function detectOS(): "Windows" | "linux/MacOS" {
const platform = window.navigator.platform.toLowerCase(); const platform = window.navigator.platform.toLowerCase();
@@ -98,8 +107,16 @@ export const DEFAULT_SETTINGS: Settings = {
tidalQuality: "LOSSLESS", tidalQuality: "LOSSLESS",
qobuzQuality: "6", qobuzQuality: "6",
amazonQuality: "original", amazonQuality: "original",
autoOrder: "tidal-qobuz-amazon", autoOrder: "tidal-qobuz-amazon-deezer",
autoQuality: "16" 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: { export const FONT_OPTIONS: {
value: FontFamily; value: FontFamily;
@@ -194,9 +211,6 @@ function getSettingsFromLocalStorage(): Settings {
if (!('qobuzQuality' in parsed)) { if (!('qobuzQuality' in parsed)) {
parsed.qobuzQuality = "6"; parsed.qobuzQuality = "6";
} }
if (parsed.qobuzQuality === "27") {
parsed.qobuzQuality = "6";
}
if (!('amazonQuality' in parsed)) { if (!('amazonQuality' in parsed)) {
parsed.amazonQuality = "original"; parsed.amazonQuality = "original";
} }
@@ -206,6 +220,9 @@ function getSettingsFromLocalStorage(): Settings {
if (!('autoQuality' in parsed)) { if (!('autoQuality' in parsed)) {
parsed.autoQuality = "16"; parsed.autoQuality = "16";
} }
if (!('allowFallback' in parsed)) {
parsed.allowFallback = true;
}
return { ...DEFAULT_SETTINGS, ...parsed }; return { ...DEFAULT_SETTINGS, ...parsed };
} }
} }
@@ -270,9 +287,6 @@ export async function loadSettings(): Promise<Settings> {
if (!('qobuzQuality' in parsed)) { if (!('qobuzQuality' in parsed)) {
parsed.qobuzQuality = "6"; parsed.qobuzQuality = "6";
} }
if (parsed.qobuzQuality === "27") {
parsed.qobuzQuality = "6";
}
if (!('amazonQuality' in parsed)) { if (!('amazonQuality' in parsed)) {
parsed.amazonQuality = "original"; parsed.amazonQuality = "original";
} }
@@ -282,6 +296,24 @@ export async function loadSettings(): Promise<Settings> {
if (!('autoQuality' in parsed)) { if (!('autoQuality' in parsed)) {
parsed.autoQuality = "16"; parsed.autoQuality = "16";
} }
if (!('allowFallback' in parsed)) {
parsed.allowFallback = true;
}
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 }; cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
return cachedSettings!; return cachedSettings!;
} }
@@ -307,6 +339,7 @@ export interface TemplateData {
track?: number; track?: number;
disc?: number; disc?: number;
year?: string; year?: string;
date?: string;
playlist?: string; playlist?: string;
} }
export function parseTemplate(template: string, data: TemplateData): 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(/\{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(/\{disc\}/g, data.disc ? String(data.disc) : "1");
result = result.replace(/\{year\}/g, data.year || "0000"); 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 || ""); result = result.replace(/\{playlist\}/g, data.playlist || "");
return result; return result;
} }
@@ -336,6 +370,7 @@ export async function saveSettings(settings: Settings): Promise<void> {
cachedSettings = settings; cachedSettings = settings;
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
await SaveToBackend(settings as any); await SaveToBackend(settings as any);
window.dispatchEvent(new CustomEvent('settingsUpdated', { detail: settings }));
} }
catch (error) { catch (error) {
console.error("Failed to save settings:", 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)); return twMerge(clsx(inputs));
} }
export function sanitizePath(input: string, os: string): string { export function sanitizePath(input: string, os: string): string {
let sanitized = input.trim(); const sanitized = input.trim();
if (os === "Windows") { if (os === "Windows") {
return sanitized.replace(/[<>:"/\\|?*]/g, "_"); 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; total_discs?: number;
disc_number?: number; disc_number?: number;
external_urls: string; external_urls: string;
isrc: string;
album_type?: string; album_type?: string;
spotify_id?: string; spotify_id?: string;
album_id?: string; album_id?: string;
@@ -28,6 +27,7 @@ export interface TrackMetadata {
publisher?: string; publisher?: string;
plays?: string; plays?: string;
status?: string; status?: string;
is_explicit?: boolean;
} }
export interface TrackResponse { export interface TrackResponse {
track: TrackMetadata; track: TrackMetadata;
@@ -108,8 +108,7 @@ export interface ArtistResponse {
} }
export type SpotifyMetadataResponse = TrackResponse | AlbumResponse | PlaylistResponse | ArtistDiscographyResponse | ArtistResponse; export type SpotifyMetadataResponse = TrackResponse | AlbumResponse | PlaylistResponse | ArtistDiscographyResponse | ArtistResponse;
export interface DownloadRequest { export interface DownloadRequest {
isrc: string; service: "tidal" | "qobuz" | "amazon" | "deezer";
service: "tidal" | "qobuz" | "amazon";
query?: string; query?: string;
track_name?: string; track_name?: string;
artist_name?: string; artist_name?: string;
@@ -138,6 +137,9 @@ export interface DownloadRequest {
copyright?: string; copyright?: string;
publisher?: string; publisher?: string;
spotify_url?: string; spotify_url?: string;
use_first_artist_only?: boolean;
use_single_genre?: boolean;
embed_genre?: boolean;
} }
export interface DownloadResponse { export interface DownloadResponse {
success: boolean; success: boolean;
@@ -202,9 +204,11 @@ export interface TrackAvailability {
tidal: boolean; tidal: boolean;
amazon: boolean; amazon: boolean;
qobuz: boolean; qobuz: boolean;
deezer: boolean;
tidal_url?: string; tidal_url?: string;
amazon_url?: string; amazon_url?: string;
qobuz_url?: string; qobuz_url?: string;
deezer_url?: string;
} }
export interface CoverDownloadRequest { export interface CoverDownloadRequest {
cover_url: string; 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 path from "path";
import fs from "fs";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
const wailsJsonPath = path.resolve(__dirname, "../wails.json");
const wailsJson = JSON.parse(fs.readFileSync(wailsJsonPath, "utf-8"));
const appVersion = wailsJson.info.productVersion;
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
resolve: { resolve: {
@@ -9,4 +13,7 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),
}, },
}, },
define: {
__APP_VERSION__: JSON.stringify(appVersion),
},
}); });
+3 -3
View File
@@ -1,6 +1,6 @@
module spotiflac module github.com/afkarxyz/SpotiFLAC
go 1.25.5 go 1.26
require ( require (
github.com/bogem/id3v2/v2 v2.1.4 github.com/bogem/id3v2/v2 v2.1.4
@@ -12,6 +12,7 @@ require (
github.com/ulikunitz/xz v0.5.15 github.com/ulikunitz/xz v0.5.15
github.com/wailsapp/wails/v2 v2.11.0 github.com/wailsapp/wails/v2 v2.11.0
go.etcd.io/bbolt v1.4.3 go.etcd.io/bbolt v1.4.3
golang.org/x/text v0.31.0
) )
require ( require (
@@ -45,5 +46,4 @@ require (
golang.org/x/crypto v0.45.0 // indirect golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.38.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 ( import (
"embed" "embed"
"encoding/json"
"log" "log"
"github.com/afkarxyz/SpotiFLAC/backend"
"github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver" "github.com/wailsapp/wails/v2/pkg/options/assetserver"
@@ -13,8 +16,21 @@ import (
//go:embed all:frontend/dist //go:embed all:frontend/dist
var assets embed.FS var assets embed.FS
//go:embed wails.json
var wailsJSON []byte
func main() { 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() app := NewApp()
err := wails.Run(&options.App{ err := wails.Run(&options.App{
+2 -2
View File
@@ -12,10 +12,10 @@
}, },
"info": { "info": {
"productName": "SpotiFLAC", "productName": "SpotiFLAC",
"productVersion": "7.0.6", "productVersion": "7.1.0",
"copyright": "© 2026 afkarxyz" "copyright": "© 2026 afkarxyz"
}, },
"wailsjsdir": "./frontend", "wailsjsdir": "./frontend",
"assetdir": "./frontend/dist", "assetdir": "./frontend/dist",
"reloaddirs": "./frontend/src" "reloaddirs": "./frontend/src"
} }