From 25233349b9ab6957e3cfad42cfa84b395c7d4179 Mon Sep 17 00:00:00 2001 From: afkarxyz Date: Tue, 27 Jan 2026 06:16:05 +0700 Subject: [PATCH] v7.0.7 --- README.md | 17 +- app.go | 98 ++- backend/amazon.go | 503 ++---------- backend/filename.go | 22 +- backend/folder.go | 23 + backend/history.go | 179 ++++- backend/qobuz.go | 245 ++++-- backend/songlink.go | 6 +- backend/spotfetch.go | 88 ++- backend/spotify_metadata.go | 194 +++-- backend/tidal.go | 355 +++------ backend/uploader.go | 173 +++++ frontend/package.json | 1 + frontend/package.json.md5 | 2 +- frontend/pnpm-lock.yaml | 33 + frontend/src/App.tsx | 70 +- frontend/src/assets/icons/next.svg | 27 + frontend/src/components/AboutPage.tsx | 368 +++++---- frontend/src/components/AlbumInfo.tsx | 12 +- frontend/src/components/ArtistInfo.tsx | 174 ++++- frontend/src/components/DragDropTextarea.tsx | 182 +++++ frontend/src/components/HistoryPage.tsx | 765 +++++++++++++------ frontend/src/components/PlaylistInfo.tsx | 14 +- frontend/src/components/SearchBar.tsx | 313 ++++---- frontend/src/components/SettingsPage.tsx | 85 +-- frontend/src/components/Sidebar.tsx | 2 +- frontend/src/components/TrackInfo.tsx | 11 +- frontend/src/components/TrackList.tsx | 2 + frontend/src/components/ui/scroll-area.tsx | 18 + frontend/src/hooks/useDownload.ts | 12 +- frontend/src/hooks/useMetadata.ts | 113 ++- frontend/src/hooks/usePreview.ts | 10 +- frontend/src/hooks/useTypingEffect.ts | 35 + frontend/src/lib/settings.ts | 10 +- frontend/src/types/api.ts | 1 + frontend/src/vite-env.d.ts | 2 + frontend/vite.config.ts | 7 + wails.json | 2 +- 38 files changed, 2492 insertions(+), 1682 deletions(-) create mode 100644 backend/uploader.go create mode 100644 frontend/src/assets/icons/next.svg create mode 100644 frontend/src/components/DragDropTextarea.tsx create mode 100644 frontend/src/components/ui/scroll-area.tsx create mode 100644 frontend/src/hooks/useTypingEffect.ts create mode 100644 frontend/src/vite-env.d.ts diff --git a/README.md b/README.md index 81e0313..e72ba83 100644 --- a/README.md +++ b/README.md @@ -24,16 +24,23 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account ## Other projects -### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader) -Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API. +### [SpotiFLAC Next](https://github.com/spotiverse/SpotiFLAC-Next) + +Get Spotify tracks in Hi-Res lossless FLACs — no account required. + +### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader) + +Get Spotify tracks in MP3 and FLAC via spotidownloader.com ### [SpotubeDL](https://spotubedl.com) + Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus with High Quality. ### [SpotiFLAC (Mobile)](https://github.com/zarzet/SpotiFLAC-Mobile) + SpotiFLAC for Android & iOS — maintained by [@zarzet](https://github.com/zarzet) -## FAQ (Frequently Asked Questions) +## FAQ ### Is this software free? @@ -78,6 +85,7 @@ This project is for **educational and private use only**. The developer does not **SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service. You are solely responsible for: + 1. Ensuring your use of this software complies with your local laws. 2. Reading and adhering to the Terms of Service of the respective platforms. 3. Any legal consequences resulting from the misuse of this tool. @@ -87,8 +95,7 @@ The software is provided "as is", without warranty of any kind. The author assum ## API Credits - **Tidal**: [hifi-api](https://github.com/binimum/hifi-api) -- **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf) -- **Amazon Music**: [doubledouble.top](https://doubledouble.top), [lucida.to](https://lucida.to) +- **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf), [jumo-dl](https://jumo-dl.pages.dev/) > [!TIP] > diff --git a/app.go b/app.go index 4b0b338..bc4f415 100644 --- a/app.go +++ b/app.go @@ -79,6 +79,9 @@ type DownloadRequest struct { SpotifyTotalDiscs int `json:"spotify_total_discs,omitempty"` Copyright string `json:"copyright,omitempty"` Publisher string `json:"publisher,omitempty"` + PlaylistName string `json:"playlist_name,omitempty"` + PlaylistOwner string `json:"playlist_owner,omitempty"` + AllowFallback bool `json:"allow_fallback"` } type DownloadResponse struct { @@ -90,14 +93,14 @@ type DownloadResponse struct { ItemID string `json:"item_id,omitempty"` } -func (a *App) GetStreamingURLs(spotifyTrackID string) (string, error) { +func (a *App) GetStreamingURLs(spotifyTrackID string, region string) (string, error) { if spotifyTrackID == "" { return "", fmt.Errorf("spotify track ID is required") } - fmt.Printf("[GetStreamingURLs] Called for track ID: %s\n", spotifyTrackID) + fmt.Printf("[GetStreamingURLs] Called for track ID: %s, Region: %s\n", spotifyTrackID, region) client := backend.NewSongLinkClient() - urls, err := client.GetAllURLsFromSpotify(spotifyTrackID) + urls, err := client.GetAllURLsFromSpotify(spotifyTrackID, region) if err != nil { return "", err } @@ -201,7 +204,12 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { req.OutputDir = "." } else { - req.OutputDir = backend.NormalizePath(req.OutputDir) + if req.PlaylistName != "" { + sanitizedPlaylist := backend.SanitizeFilename(req.PlaylistName) + req.OutputDir = filepath.Join(req.OutputDir, sanitizedPlaylist) + } + + req.OutputDir = backend.SanitizeFolderPath(req.OutputDir) } if req.AudioFormat == "" { @@ -281,7 +289,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { } if req.TrackName != "" && req.ArtistName != "" { - expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.FilenameFormat, req.TrackNumber, req.Position, req.SpotifyDiscNumber, req.UseAlbumTrackNumber) + expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.SpotifyDiscNumber, req.UseAlbumTrackNumber) expectedPath := filepath.Join(req.OutputDir, expectedFilename) if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 { @@ -301,8 +309,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { case "amazon": downloader := backend.NewAmazonDownloader() if req.ServiceURL != "" { - - filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL) + filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL) } else { if req.SpotifyID == "" { return DownloadResponse{ @@ -310,15 +317,14 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { Error: "Spotify ID is required for Amazon Music", }, fmt.Errorf("spotify ID is required for Amazon Music") } - filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL) + filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL) } case "tidal": if req.ApiURL == "" || req.ApiURL == "auto" { downloader := backend.NewTidalDownloader("") if req.ServiceURL != "" { - - filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL) + filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback) } else { if req.SpotifyID == "" { return DownloadResponse{ @@ -326,14 +332,12 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { Error: "Spotify ID is required for Tidal", }, fmt.Errorf("spotify ID is required for Tidal") } - - filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL) + filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback) } } else { downloader := backend.NewTidalDownloader(req.ApiURL) if req.ServiceURL != "" { - - filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL) + filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback) } else { if req.SpotifyID == "" { return DownloadResponse{ @@ -341,8 +345,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { Error: "Spotify ID is required for Tidal", }, fmt.Errorf("spotify ID is required for Tidal") } - - filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL) + filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback) } } @@ -384,7 +387,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { Error: "ISRC is required for Qobuz (could not fetch from Deezer)", }, fmt.Errorf("ISRC is required for Qobuz") } - filename, err = downloader.DownloadByISRC(deezerISRC, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL) + filename, err = downloader.DownloadByISRC(deezerISRC, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback) default: return DownloadResponse{ @@ -513,6 +516,12 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { item.Format = strings.ToUpper(ext[1:]) } } + + switch item.Format { + case "6", "7", "27": + item.Format = "FLAC" + } + backend.AddHistoryItem(item, "SpotiFLAC") }(filename, req.TrackName, req.ArtistName, req.AlbumName, req.SpotifyID, req.CoverURL, req.AudioFormat) } @@ -596,6 +605,30 @@ func (a *App) ClearDownloadHistory() error { return backend.ClearHistory("SpotiFLAC") } +func (a *App) DeleteDownloadHistoryItem(id string) error { + return backend.DeleteHistoryItem(id, "SpotiFLAC") +} + +func (a *App) GetFetchHistory() ([]backend.FetchHistoryItem, error) { + return backend.GetFetchHistoryItems("SpotiFLAC") +} + +func (a *App) AddFetchHistory(item backend.FetchHistoryItem) error { + return backend.AddFetchHistoryItem(item, "SpotiFLAC") +} + +func (a *App) ClearFetchHistory() error { + return backend.ClearFetchHistory("SpotiFLAC") +} + +func (a *App) DeleteFetchHistoryItem(id string) error { + return backend.DeleteFetchHistoryItem(id, "SpotiFLAC") +} + +func (a *App) ClearFetchHistoryByType(itemType string) error { + return backend.ClearFetchHistoryByType(itemType, "SpotiFLAC") +} + func (a *App) AnalyzeTrack(filePath string) (string, error) { if filePath == "" { return "", fmt.Errorf("file path is required") @@ -987,6 +1020,27 @@ func (a *App) RenameFileTo(oldPath, newName string) error { return os.Rename(oldPath, newPath) } +func (a *App) UploadImage(filePath string) (string, error) { + return backend.UploadToSendNow(filePath) +} + +func (a *App) UploadImageBytes(filename string, base64Data string) (string, error) { + + if idx := strings.Index(base64Data, ","); idx != -1 { + base64Data = base64Data[idx+1:] + } + + data, err := base64.StdEncoding.DecodeString(base64Data) + if err != nil { + return "", fmt.Errorf("failed to decode base64: %v", err) + } + return backend.UploadBytesToSendNow(filename, data) +} + +func (a *App) SelectImageVideo() ([]string, error) { + return backend.SelectImageVideoDialog(a.ctx) +} + func (a *App) ReadImageAsBase64(filePath string) (string, error) { content, err := os.ReadFile(filePath) if err != nil { @@ -1026,6 +1080,7 @@ type CheckFileExistenceRequest struct { FilenameFormat string `json:"filename_format,omitempty"` IncludeTrackNumber bool `json:"include_track_number,omitempty"` AudioFormat string `json:"audio_format,omitempty"` + RelativePath string `json:"relative_path,omitempty"` } type CheckFileExistenceResult struct { @@ -1088,6 +1143,8 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR t.AlbumArtist, t.ReleaseDate, filenameFormat, + "", + "", t.IncludeTrackNumber, trackNumber, t.DiscNumber, @@ -1096,7 +1153,12 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR expectedFilename := strings.TrimSuffix(expectedFilenameBase, ".flac") + fileExt - expectedPath := filepath.Join(outputDir, expectedFilename) + targetDir := outputDir + if t.RelativePath != "" { + targetDir = filepath.Join(outputDir, t.RelativePath) + } + + expectedPath := filepath.Join(targetDir, expectedFilename) if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 { res.Exists = true diff --git a/backend/amazon.go b/backend/amazon.go index d620f27..e109cb9 100644 --- a/backend/amazon.go +++ b/backend/amazon.go @@ -1,15 +1,11 @@ package backend import ( - "bytes" - "crypto/tls" "encoding/base64" "encoding/json" "fmt" "io" - "math/rand" "net/http" - "net/http/cookiejar" "net/url" "os" "path/filepath" @@ -19,11 +15,8 @@ import ( ) type AmazonDownloader struct { - client *http.Client - regions []string - lastAPICallTime time.Time - apiCallCount int - apiCallResetTime time.Time + client *http.Client + regions []string } type SongLinkResponse struct { @@ -32,35 +25,13 @@ type SongLinkResponse struct { } `json:"linksByPlatform"` } -type DoubleDoubleSubmitResponse struct { - Success bool `json:"success"` - ID string `json:"id"` -} - -type DoubleDoubleStatusResponse struct { - Status string `json:"status"` - FriendlyStatus string `json:"friendlyStatus"` - URL string `json:"url"` - Current struct { - Name string `json:"name"` - Artist string `json:"artist"` - } `json:"current"` -} - -type LucidaLoadResponse struct { - Success bool `json:"success"` - Server string `json:"server"` - Handoff string `json:"handoff"` - Error string `json:"error"` -} - -type LucidaStatusResponse struct { - Status string `json:"status"` - Message string `json:"message"` - Progress struct { - Current int64 `json:"current"` - Total int64 `json:"total"` - } `json:"progress"` +type AfkarXYZResponse struct { + Success bool `json:"success"` + Data struct { + DirectLink string `json:"direct_link"` + FileName string `json:"file_name"` + FileSize int64 `json:"file_size"` + } `json:"data"` } func NewAmazonDownloader() *AmazonDownloader { @@ -68,93 +39,35 @@ func NewAmazonDownloader() *AmazonDownloader { client: &http.Client{ Timeout: 120 * time.Second, }, - regions: []string{"us", "eu"}, - apiCallResetTime: time.Now(), + regions: []string{"us", "eu"}, } } -func (a *AmazonDownloader) getRandomUserAgent() string { - return fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d", - rand.Intn(4)+11, rand.Intn(5)+4, - rand.Intn(7)+530, rand.Intn(7)+30, - rand.Intn(25)+80, rand.Intn(1500)+3000, rand.Intn(65)+60, - rand.Intn(7)+530, rand.Intn(6)+30) -} - func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) { - now := time.Now() - if now.Sub(a.apiCallResetTime) >= time.Minute { - a.apiCallCount = 0 - a.apiCallResetTime = now - } + spotifyBase := "https://open.spotify.com/track/" + spotifyURL := fmt.Sprintf("%s%s", spotifyBase, spotifyTrackID) - if a.apiCallCount >= 9 { - waitTime := time.Minute - now.Sub(a.apiCallResetTime) - if waitTime > 0 { - fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second)) - time.Sleep(waitTime) - a.apiCallCount = 0 - a.apiCallResetTime = time.Now() - } - } - - if !a.lastAPICallTime.IsZero() { - timeSinceLastCall := now.Sub(a.lastAPICallTime) - minDelay := 7 * time.Second - if timeSinceLastCall < minDelay { - waitTime := minDelay - timeSinceLastCall - fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second)) - time.Sleep(waitTime) - } - } - - spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") - spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) - - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") - apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL)) + apiBase := "https://api.song.link/v1-alpha.1/links?url=" + apiURL := fmt.Sprintf("%s%s", apiBase, url.QueryEscape(spotifyURL)) req, err := http.NewRequest("GET", apiURL, nil) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("User-Agent", a.getRandomUserAgent()) - fmt.Println("Getting Amazon URL...") - maxRetries := 3 - var resp *http.Response - for i := 0; i < maxRetries; i++ { - resp, err = a.client.Do(req) - if err != nil { - return "", fmt.Errorf("failed to get Amazon URL: %w", err) - } - - a.lastAPICallTime = time.Now() - a.apiCallCount++ - - if resp.StatusCode == 429 { - resp.Body.Close() - if i < maxRetries-1 { - waitTime := 15 * time.Second - fmt.Printf("Rate limited by API, waiting %v before retry...\n", waitTime) - time.Sleep(waitTime) - continue - } - return "", fmt.Errorf("API rate limit exceeded after %d retries", maxRetries) - } - - if resp.StatusCode != 200 { - resp.Body.Close() - return "", fmt.Errorf("API returned status %d", resp.StatusCode) - } - - break + resp, err := a.client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to get Amazon URL: %w", err) } defer resp.Body.Close() + if resp.StatusCode != 200 { + return "", fmt.Errorf("API returned status %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("failed to read response body: %w", err) @@ -194,177 +107,65 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin return amazonURL, nil } -func (a *AmazonDownloader) extractData(html string, patterns []string) string { - for _, p := range patterns { - re := regexp.MustCompile(p) - matches := re.FindStringSubmatch(html) - if len(matches) > 1 { - return matches[1] - } - } - return "" -} - -func (a *AmazonDownloader) DownloadFromLucida(amazonURL, outputDir, quality string) (string, error) { - tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - } - jar, _ := cookiejar.New(nil) - client := &http.Client{ - Transport: tr, - Jar: jar, - Timeout: 120 * time.Second, - } - - userAgent := a.getRandomUserAgent() - - fmt.Printf("Initializing lucida for Amazon Music... (Target: %s)\n", amazonURL) - lucidaBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9sdWNpZGEudG8vP3VybD0lcyZjb3VudHJ5PWF1dG8=") - lucidaURL := fmt.Sprintf(string(lucidaBase), url.QueryEscape(amazonURL)) - req, _ := http.NewRequest("GET", lucidaURL, nil) - req.Header.Set("User-Agent", userAgent) - - resp, err := client.Do(req) +func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality string) (string, error) { + apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL) + req, err := http.NewRequest("GET", apiURL, nil) if err != nil { return "", err } - defer resp.Body.Close() - bodyBytes, _ := io.ReadAll(resp.Body) - html := string(bodyBytes) - - token := a.extractData(html, []string{`token:"([^"]+)"`, `"token"\s*:\s*"([^"]+)"`}) - streamURL := a.extractData(html, []string{`"url":"([^"]+)"`, `url:"([^"]+)"`}) - expiry := a.extractData(html, []string{`tokenExpiry:(\d+)`, `"tokenExpiry"\s*:\s*(\d+)`}) - - if token == "" || streamURL == "" { - errorMsg := a.extractData(html, []string{`error:"([^"]+)"`, `"error"\s*:\s*"([^"]+)"`}) - if errorMsg != "" { - return "", fmt.Errorf("lucida error: %s", errorMsg) - } - return "", fmt.Errorf("could not extract required data from lucida") - } - - decodedToken := token - if secondBase64, err := base64.StdEncoding.DecodeString(token); err == nil { - if firstBase64, err := base64.StdEncoding.DecodeString(string(secondBase64)); err == nil { - decodedToken = string(firstBase64) - } - } - - streamURL = strings.ReplaceAll(streamURL, `\/`, `/`) - fmt.Printf("Fetching Amazon stream via Lucida...\n") - - loadPayload := map[string]interface{}{ - "account": map[string]string{"id": "auto", "type": "country"}, - "compat": "false", "downscale": "original", "handoff": true, - "metadata": true, "private": true, - "token": map[string]interface{}{"primary": decodedToken, "expiry": expiry}, - "upload": map[string]bool{"enabled": false}, - "url": streamURL, - } - - payloadBytes, _ := json.Marshal(loadPayload) - loadAPI, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9sdWNpZGEudG8vYXBpL2xvYWQ/dXJsPS9hcGkvZmV0Y2gvc3RyZWFtL3Yy") - req, _ = http.NewRequest("POST", string(loadAPI), bytes.NewBuffer(payloadBytes)) - req.Header.Set("User-Agent", userAgent) - req.Header.Set("Content-Type", "application/json") - - for _, cookie := range client.Jar.Cookies(req.URL) { - if cookie.Name == "csrf_token" { - req.Header.Set("X-CSRF-Token", cookie.Value) - } - } - - resp, err = client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - var loadData LucidaLoadResponse - json.NewDecoder(resp.Body).Decode(&loadData) - - if !loadData.Success { - return "", fmt.Errorf("lucida load request failed: %s", loadData.Error) - } - - serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") - completionBase, _ := base64.StdEncoding.DecodeString("Lmx1Y2lkYS50by9hcGkvZmV0Y2gvcmVxdWVzdC8=") - completionURL := fmt.Sprintf("%s%s%s%s", string(serviceBase), loadData.Server, string(completionBase), loadData.Handoff) - fmt.Println("Processing on Lucida server...") - - var finalStatus LucidaStatusResponse - for { - req, _ = http.NewRequest("GET", completionURL, nil) - req.Header.Set("User-Agent", userAgent) - resp, err = client.Do(req) - if err != nil { - return "", err - } - - json.NewDecoder(resp.Body).Decode(&finalStatus) - resp.Body.Close() - - if finalStatus.Status == "completed" { - fmt.Println("\nTrack processing completed!") - break - } else if finalStatus.Status == "error" { - return "", fmt.Errorf("lucida processing failed: %s", finalStatus.Message) - } else if finalStatus.Progress.Total > 0 { - percent := (finalStatus.Progress.Current * 100) / finalStatus.Progress.Total - fmt.Printf("\rLucida Progress: %d%%", percent) - } - time.Sleep(2 * time.Second) - } - - downloadSuffix, _ := base64.StdEncoding.DecodeString("L2Rvd25sb2Fk") - downloadURL := fmt.Sprintf("%s%s%s%s%s", string(serviceBase), loadData.Server, string(completionBase), loadData.Handoff, string(downloadSuffix)) - req, _ = http.NewRequest("GET", downloadURL, nil) - req.Header.Set("User-Agent", userAgent) - resp, err = client.Do(req) + fmt.Printf("Fetching from AfkarXYZ...\n") + resp, err := a.client.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != 200 { - return "", fmt.Errorf("lucida download failed with status %d", resp.StatusCode) + return "", fmt.Errorf("AfkarXYZ API returned status %d", resp.StatusCode) } - fileName := "track.flac" - contentDisp := resp.Header.Get("Content-Disposition") - if contentDisp != "" { - re := regexp.MustCompile(`filename[*]?=([^;]+)`) - if matches := re.FindStringSubmatch(contentDisp); len(matches) > 1 { - rawName := strings.Trim(matches[1], `"'`) - if strings.HasPrefix(rawName, "UTF-8''") { - decodedName, _ := url.PathUnescape(rawName[7:]) - fileName = decodedName - } else { - fileName = rawName - } - - reg := regexp.MustCompile(`[<>:"/\\|?*]`) - fileName = reg.ReplaceAllString(fileName, "") - } + bodyBytes, _ := io.ReadAll(resp.Body) + var apiResp AfkarXYZResponse + if err := json.Unmarshal(bodyBytes, &apiResp); err != nil { + return "", fmt.Errorf("failed to decode response: %w", err) } + if !apiResp.Success || apiResp.Data.DirectLink == "" { + return "", fmt.Errorf("AfkarXYZ failed or no link found") + } + + downloadURL := apiResp.Data.DirectLink + fileName := apiResp.Data.FileName + if fileName == "" { + fileName = "track.flac" + } + + reg := regexp.MustCompile(`[<>:"/\\|?*]`) + fileName = reg.ReplaceAllString(fileName, "") filePath := filepath.Join(outputDir, fileName) + out, err := os.Create(filePath) if err != nil { return "", err } defer out.Close() - fmt.Printf("Downloading from Lucida: %s\n", fileName) + dlReq, _ := http.NewRequest("GET", downloadURL, nil) + dlResp, err := a.client.Do(dlReq) + if err != nil { + return "", err + } + defer dlResp.Body.Close() + + fmt.Printf("Downloading from AfkarXYZ: %s\n", fileName) pw := NewProgressWriter(out) - _, err = io.Copy(pw, resp.Body) + _, err = io.Copy(pw, dlResp.Body) if err != nil { out.Close() os.Remove(filePath) - return "", fmt.Errorf("failed to write file: %w", err) + return "", err } fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024)) @@ -372,196 +173,10 @@ func (a *AmazonDownloader) DownloadFromLucida(amazonURL, outputDir, quality stri } func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality string) (string, error) { - fmt.Println("Attempting download via Lucida (Priority)...") - filePath, err := a.DownloadFromLucida(amazonURL, outputDir, quality) - if err == nil { - return filePath, nil - } - fmt.Printf("Lucida failed: %v\nTrying Double-Double as fallback...\n", err) - - var lastError error - lastError = err - - for _, region := range a.regions { - fmt.Printf("\nTrying region: %s...\n", region) - - serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") - serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") - baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain)) - - encodedURL := url.QueryEscape(amazonURL) - submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL) - - req, err := http.NewRequest("GET", submitURL, nil) - if err != nil { - lastError = fmt.Errorf("failed to create request: %w", err) - continue - } - - req.Header.Set("User-Agent", a.getRandomUserAgent()) - - fmt.Println("Submitting download request...") - resp, err := a.client.Do(req) - if err != nil { - lastError = fmt.Errorf("failed to submit request: %w", err) - continue - } - - if resp.StatusCode != 200 { - resp.Body.Close() - lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode) - continue - } - - var submitResp DoubleDoubleSubmitResponse - if err := json.NewDecoder(resp.Body).Decode(&submitResp); err != nil { - resp.Body.Close() - lastError = fmt.Errorf("failed to decode submit response: %w", err) - continue - } - resp.Body.Close() - - if !submitResp.Success || submitResp.ID == "" { - lastError = fmt.Errorf("submit request failed") - continue - } - - downloadID := submitResp.ID - fmt.Printf("Download ID: %s\n", downloadID) - - statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID) - fmt.Println("Waiting for download to complete...") - - maxWait := 300 * time.Second - elapsed := time.Duration(0) - pollInterval := 3 * time.Second - - for elapsed < maxWait { - time.Sleep(pollInterval) - elapsed += pollInterval - - statusReq, err := http.NewRequest("GET", statusURL, nil) - if err != nil { - continue - } - - statusReq.Header.Set("User-Agent", a.getRandomUserAgent()) - - statusResp, err := a.client.Do(statusReq) - if err != nil { - fmt.Printf("\rStatus check failed, retrying...") - continue - } - - if statusResp.StatusCode != 200 { - statusResp.Body.Close() - fmt.Printf("\rStatus check failed (status %d), retrying...", statusResp.StatusCode) - continue - } - - var status DoubleDoubleStatusResponse - if err := json.NewDecoder(statusResp.Body).Decode(&status); err != nil { - statusResp.Body.Close() - fmt.Printf("\rInvalid JSON response, retrying...") - continue - } - statusResp.Body.Close() - - if status.Status == "done" { - fmt.Println("\nDownload ready!") - - fileURL := status.URL - if strings.HasPrefix(fileURL, "./") { - fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:]) - } else if strings.HasPrefix(fileURL, "/") { - fileURL = fmt.Sprintf("%s%s", baseURL, fileURL) - } - - trackName := status.Current.Name - artist := status.Current.Artist - - fmt.Printf("Downloading: %s - %s\n", artist, trackName) - - downloadReq, err := http.NewRequest("GET", fileURL, nil) - if err != nil { - lastError = fmt.Errorf("failed to create download request: %w", err) - break - } - - downloadReq.Header.Set("User-Agent", a.getRandomUserAgent()) - - fileResp, err := a.client.Do(downloadReq) - if err != nil { - lastError = fmt.Errorf("failed to download file: %w", err) - break - } - defer fileResp.Body.Close() - - if fileResp.StatusCode != 200 { - lastError = fmt.Errorf("download failed with status %d", fileResp.StatusCode) - break - } - - fileName := fmt.Sprintf("%s - %s.flac", artist, trackName) - for _, char := range `<>:"/\|?*` { - fileName = strings.ReplaceAll(fileName, string(char), "") - } - fileName = strings.TrimSpace(fileName) - - filePath := filepath.Join(outputDir, fileName) - - out, err := os.Create(filePath) - if err != nil { - lastError = fmt.Errorf("failed to create file: %w", err) - break - } - defer out.Close() - - fmt.Println("Downloading...") - - pw := NewProgressWriter(out) - _, err = io.Copy(pw, fileResp.Body) - if err != nil { - out.Close() - return "", fmt.Errorf("failed to write file: %w", err) - } - - fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024)) - fmt.Println("Download complete!") - return filePath, nil - - } else if status.Status == "error" { - errorMsg := status.FriendlyStatus - if errorMsg == "" { - errorMsg = "Unknown error" - } - lastError = fmt.Errorf("processing failed: %s", errorMsg) - break - } else { - - friendlyStatus := status.FriendlyStatus - if friendlyStatus == "" { - friendlyStatus = status.Status - } - fmt.Printf("\r%s...", friendlyStatus) - } - } - - if elapsed >= maxWait { - lastError = fmt.Errorf("download timeout") - fmt.Printf("\nError with %s region: %v\n", region, lastError) - continue - } - - if lastError != nil { - fmt.Printf("\nError with %s region: %v\n", region, lastError) - } - } - - return "", fmt.Errorf("all regions failed. Last error: %v", lastError) + return a.DownloadFromAfkarXYZ(amazonURL, outputDir, quality) } -func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) { +func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) { if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { @@ -570,7 +185,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename } if spotifyTrackName != "" && spotifyArtistName != "" { - expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, filenameFormat, includeTrackNumber, position, spotifyDiscNumber, false) + expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyDiscNumber, false) expectedPath := filepath.Join(outputDir, expectedFilename) if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 { @@ -696,12 +311,12 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename return filePath, nil } -func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) { +func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) { amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID) if err != nil { return "", err } - return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL) + return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL) } diff --git a/backend/filename.go b/backend/filename.go index c9c6cb1..63bc036 100644 --- a/backend/filename.go +++ b/backend/filename.go @@ -9,12 +9,15 @@ import ( "unicode/utf8" ) -func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool) string { +func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool) string { - safeTitle := sanitizeFilename(trackName) - safeArtist := sanitizeFilename(artistName) - safeAlbum := sanitizeFilename(albumName) - safeAlbumArtist := sanitizeFilename(albumArtist) + safeTitle := SanitizeFilename(trackName) + safeArtist := SanitizeFilename(artistName) + safeAlbum := SanitizeFilename(albumName) + safeAlbumArtist := SanitizeFilename(albumArtist) + + safePlaylist := SanitizeFilename(playlistName) + safeCreator := SanitizeFilename(playlistOwner) year := "" if len(releaseDate) >= 4 { @@ -30,6 +33,8 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas filename = strings.ReplaceAll(filename, "{album}", safeAlbum) filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist) filename = strings.ReplaceAll(filename, "{year}", year) + filename = strings.ReplaceAll(filename, "{playlist}", safePlaylist) + filename = strings.ReplaceAll(filename, "{creator}", safeCreator) if discNumber > 0 { filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) @@ -64,7 +69,7 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas return filename + ".flac" } -func sanitizeFilename(name string) string { +func SanitizeFilename(name string) string { sanitized := strings.ReplaceAll(name, "/", " ") @@ -148,7 +153,8 @@ func SanitizeFolderPath(folderPath string) string { return strings.Join(sanitizedParts, sep) } -func sanitizeFolderName(name string) string { +func sanitizeFolderName(name string) string { return SanitizeFilename(name) } - return sanitizeFilename(name) +func sanitizeFilename(name string) string { + return SanitizeFilename(name) } diff --git a/backend/folder.go b/backend/folder.go index 77f1ac0..f7934f2 100644 --- a/backend/folder.go +++ b/backend/folder.go @@ -74,3 +74,26 @@ func SelectFileDialog(ctx context.Context) (string, error) { return selectedFile, nil } + +func SelectImageVideoDialog(ctx context.Context) ([]string, error) { + options := wailsRuntime.OpenDialogOptions{ + Title: "Select Image or Video", + Filters: []wailsRuntime.FileFilter{ + { + DisplayName: "Supported Files (*.jpg, *.png, *.mp4, *.mov, ...)", + Pattern: "*.jpg;*.jpeg;*.png;*.gif;*.webp;*.mp4;*.mkv;*.webm;*.mov", + }, + { + DisplayName: "All Files (*.*)", + Pattern: "*.*", + }, + }, + } + + selectedPaths, err := wailsRuntime.OpenMultipleFilesDialog(ctx, options) + if err != nil { + return nil, err + } + + return selectedPaths, nil +} diff --git a/backend/history.go b/backend/history.go index 606be35..11b6856 100644 --- a/backend/history.go +++ b/backend/history.go @@ -75,7 +75,10 @@ func AddHistoryItem(item HistoryItem, appName string) error { } } return historyDB.Update(func(tx *bolt.Tx) error { - b := tx.Bucket([]byte(historyBucket)) + b, err := tx.CreateBucketIfNotExists([]byte(historyBucket)) + if err != nil { + return err + } id, _ := b.NextSequence() item.ID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), id) @@ -147,3 +150,177 @@ func ClearHistory(appName string) error { return tx.DeleteBucket([]byte(historyBucket)) }) } + +type FetchHistoryItem struct { + ID string `json:"id"` + URL string `json:"url"` + Type string `json:"type"` + Name string `json:"name"` + Info string `json:"info"` + Image string `json:"image"` + Data string `json:"data"` + Timestamp int64 `json:"timestamp"` +} + +const ( + fetchHistoryBucket = "FetchHistory" +) + +func AddFetchHistoryItem(item FetchHistoryItem, appName string) error { + if historyDB == nil { + if err := InitHistoryDB(appName); err != nil { + return err + } + } + return historyDB.Update(func(tx *bolt.Tx) error { + b, err := tx.CreateBucketIfNotExists([]byte(fetchHistoryBucket)) + if err != nil { + return err + } + id, _ := b.NextSequence() + + if item.URL != "" { + c := b.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + var existing FetchHistoryItem + if err := json.Unmarshal(v, &existing); err == nil { + if existing.URL == item.URL && existing.Type == item.Type { + if err := b.Delete(k); err != nil { + return err + } + } + } + } + } + + item.ID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), id) + item.Timestamp = time.Now().Unix() + + buf, err := json.Marshal(item) + if err != nil { + return err + } + + if b.Stats().KeyN >= maxHistory { + c := b.Cursor() + toDelete := maxHistory / 20 + if toDelete < 1 { + toDelete = 1 + } + count := 0 + for k, _ := c.First(); k != nil && count < toDelete; k, _ = c.Next() { + if err := b.Delete(k); err != nil { + return err + } + count++ + } + } + + return b.Put([]byte(item.ID), buf) + }) +} + +func GetFetchHistoryItems(appName string) ([]FetchHistoryItem, error) { + if historyDB == nil { + if err := InitHistoryDB(appName); err != nil { + return nil, err + } + } + var items []FetchHistoryItem + err := historyDB.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(fetchHistoryBucket)) + if b == nil { + return nil + } + c := b.Cursor() + + for k, v := c.First(); k != nil; k, v = c.Next() { + var item FetchHistoryItem + if err := json.Unmarshal(v, &item); err == nil { + items = append(items, item) + } + } + return nil + }) + + sort.Slice(items, func(i, j int) bool { + return items[i].Timestamp > items[j].Timestamp + }) + + return items, err +} + +func ClearFetchHistory(appName string) error { + if historyDB == nil { + if err := InitHistoryDB(appName); err != nil { + return err + } + } + return historyDB.Update(func(tx *bolt.Tx) error { + return tx.DeleteBucket([]byte(fetchHistoryBucket)) + }) +} + +func ClearFetchHistoryByType(itemType string, appName string) error { + if historyDB == nil { + if err := InitHistoryDB(appName); err != nil { + return err + } + } + return historyDB.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(fetchHistoryBucket)) + if b == nil { + return nil + } + + var keysToDelete [][]byte + + c := b.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + var item FetchHistoryItem + if err := json.Unmarshal(v, &item); err == nil { + if item.Type == itemType { + keysToDelete = append(keysToDelete, k) + } + } + } + + for _, k := range keysToDelete { + if err := b.Delete(k); err != nil { + return err + } + } + return nil + }) +} + +func DeleteHistoryItem(id string, appName string) error { + if historyDB == nil { + if err := InitHistoryDB(appName); err != nil { + return err + } + } + return historyDB.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(historyBucket)) + if b == nil { + return nil + } + + return b.Delete([]byte(id)) + }) +} + +func DeleteFetchHistoryItem(id string, appName string) error { + if historyDB == nil { + if err := InitHistoryDB(appName); err != nil { + return err + } + } + return historyDB.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(fetchHistoryBucket)) + if b == nil { + return nil + } + return b.Delete([]byte(id)) + }) +} diff --git a/backend/qobuz.go b/backend/qobuz.go index c641f4b..0f023a7 100644 --- a/backend/qobuz.go +++ b/backend/qobuz.go @@ -1,10 +1,10 @@ package backend import ( - "encoding/base64" "encoding/json" "fmt" "io" + "math/rand" "net/http" "os" "path/filepath" @@ -78,9 +78,8 @@ func NewQobuzDownloader() *QobuzDownloader { } func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) { - - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") - url := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, q.appID) + apiBase := "https://www.qobuz.com/api.json/0.2/track/search?query=" + url := fmt.Sprintf("%s%s&limit=1&app_id=%s", apiBase, isrc, q.appID) resp, err := q.client.Get(url) if err != nil { @@ -119,104 +118,194 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) { return &searchResp.Tracks.Items[0], nil } -func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) { - - qualityCode := quality - if qualityCode == "" { - qualityCode = "6" +func decodeXOR(data []byte) string { + text := string(data) + runes := []rune(text) + result := make([]rune, len(runes)) + for i, char := range runes { + key := rune((i * 17) % 128) + result[i] = char ^ 253 ^ key } + return string(result) +} - fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode) - fmt.Printf("Quality codes: 6=FLAC 16-bit, 7=FLAC 24-bit\n") - - primaryBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWIueWVldC5zdS9hcGkvc3RyZWFtP3RyYWNrSWQ9") - - primaryURL := fmt.Sprintf("%s%d&quality=%s", string(primaryBase), trackID, qualityCode) - fmt.Printf("Trying Primary API: %s\n", primaryURL) - - resp, err := q.client.Get(primaryURL) - if err == nil && resp.StatusCode == 200 { - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - fmt.Printf("Primary API response: %s\n", string(body)) - - var streamResp QobuzStreamResponse - if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" { - fmt.Printf("✓ Got download URL from Primary API\n") - return streamResp.URL, nil - } - } - if resp != nil { - resp.Body.Close() +func (q *QobuzDownloader) mapJumoQuality(quality string) int { + switch quality { + case "6": + return 6 + case "7": + return 7 + case "27": + return 27 + default: + return 6 } +} - fmt.Println("Primary API failed, trying Fallback API #1...") - fallbackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWJtdXNpYy54eXovYXBpL3N0cmVhbT90cmFja0lkPQ==") - fallbackURL := fmt.Sprintf("%s%d&quality=%s", string(fallbackBase), trackID, qualityCode) +func (q *QobuzDownloader) DownloadFromJumo(trackID int64, quality string) (string, error) { + formatID := q.mapJumoQuality(quality) + region := "US" + url := fmt.Sprintf("https://jumo-dl.pages.dev/file?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region) - resp, err = q.client.Get(fallbackURL) - if err == nil && resp.StatusCode == 200 { - defer resp.Body.Close() - - 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) + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(url) if err != nil { - return "", fmt.Errorf("all APIs failed to get download URL: %w", err) + return "", err } defer resp.Body.Close() if resp.StatusCode != 200 { - body, _ := io.ReadAll(resp.Body) - fmt.Printf("Fallback API #2 error response (status %d): %s\n", resp.StatusCode, string(body)) - return "", fmt.Errorf("all APIs returned non-200 status") + return "", fmt.Errorf("HTTP %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { - return "", fmt.Errorf("failed to read response body: %w", err) + return "", err + } + + var result map[string]interface{} + + if err := json.Unmarshal(body, &result); err != nil { + + decoded := decodeXOR(body) + if err := json.Unmarshal([]byte(decoded), &result); err != nil { + return "", fmt.Errorf("failed to parse JSON (plain or XOR): %w", err) + } + } + + if urlVal, ok := result["url"].(string); ok && urlVal != "" { + return urlVal, nil + } + + if data, ok := result["data"].(map[string]interface{}); ok { + if urlVal, ok := data["url"].(string); ok && urlVal != "" { + return urlVal, nil + } + } + + if linkVal, ok := result["link"].(string); ok && linkVal != "" { + return linkVal, nil + } + + return "", fmt.Errorf("URL not found in Jumo response") +} + +func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, quality string) (string, error) { + apiURL := fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality) + resp, err := q.client.Get(apiURL) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err } if len(body) == 0 { - return "", fmt.Errorf("API returned empty response") + return "", fmt.Errorf("empty body") } - fmt.Printf("Fallback API #2 response: %s\n", string(body)) - var streamResp QobuzStreamResponse - if err := json.Unmarshal(body, &streamResp); err != nil { + if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" { + return streamResp.URL, nil + } - bodyStr := string(body) - if len(bodyStr) > 200 { - bodyStr = bodyStr[:200] + "..." + return "", fmt.Errorf("invalid response") +} + +func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFallback bool) (string, error) { + qualityCode := quality + if qualityCode == "" || qualityCode == "5" { + qualityCode = "6" + } + + fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode) + + standardAPIs := []string{ + "https://dab.yeet.su/api/stream?trackId=", + "https://dabmusic.xyz/api/stream?trackId=", + "https://qobuz.squid.wtf/api/download-music?track_id=", + } + + downloadFunc := func(qual string) (string, error) { + type Provider struct { + Name string + Func func() (string, error) } - return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr) + + var providers []Provider + + for _, api := range standardAPIs { + currentAPI := api + providers = append(providers, Provider{ + Name: "Standard(" + currentAPI + ")", + Func: func() (string, error) { + return q.DownloadFromStandard(currentAPI, trackID, qual) + }, + }) + } + + providers = append(providers, Provider{ + Name: "Jumo-DL", + Func: func() (string, error) { + return q.DownloadFromJumo(trackID, qual) + }, + }) + + rand.Seed(time.Now().UnixNano()) + rand.Shuffle(len(providers), func(i, j int) { providers[i], providers[j] = providers[j], providers[i] }) + + var lastErr error + for _, p := range providers { + + fmt.Printf("Trying Provider: %s (Quality: %s)...\n", p.Name, qual) + + url, err := p.Func() + if err == nil { + fmt.Printf("✓ Success\n") + return url, nil + } + + fmt.Printf("Provider failed: %v\n", err) + lastErr = err + } + return "", lastErr } - if streamResp.URL == "" { - return "", fmt.Errorf("no download URL available from any API") + url, err := downloadFunc(qualityCode) + if err == nil { + return url, nil } - fmt.Printf("✓ Got download URL from Fallback API #2\n") - return streamResp.URL, nil + currentQuality := qualityCode + + if currentQuality == "27" && allowFallback { + fmt.Printf("⚠ Download with quality 27 failed, trying fallback to 7 (24-bit Standard)...\n") + url, err := downloadFunc("7") + if err == nil { + fmt.Println("✓ Success with fallback quality 7") + return url, nil + } + + currentQuality = "7" + } + + if currentQuality == "7" && allowFallback { + fmt.Printf("⚠ Download with quality 7 failed, trying fallback to 6 (16-bit Lossless)...\n") + url, err := downloadFunc("6") + if err == nil { + fmt.Println("✓ Success with fallback quality 6") + return url, nil + } + } + + return "", fmt.Errorf("all APIs and fallbacks failed. Last error: %v", err) } func (q *QobuzDownloader) DownloadFile(url, filepath string) error { @@ -334,7 +423,7 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t return filename + ".flac" } -func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) { +func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool) (string, error) { fmt.Printf("Fetching track info for ISRC: %s\n", deezerISRC) if outputDir != "." { @@ -362,7 +451,7 @@ func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenam fmt.Printf("Quality: %s\n", qualityInfo) fmt.Println("Getting download URL...") - downloadURL, err := q.GetDownloadURL(track.ID, quality) + downloadURL, err := q.GetDownloadURL(track.ID, quality, allowFallback) if err != nil { return "", fmt.Errorf("failed to get download URL: %w", err) } diff --git a/backend/songlink.go b/backend/songlink.go index 8ac1d37..5a9768a 100644 --- a/backend/songlink.go +++ b/backend/songlink.go @@ -42,7 +42,7 @@ func NewSongLinkClient() *SongLinkClient { } } -func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLinkURLs, error) { +func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string, region string) (*SongLinkURLs, error) { now := time.Now() if now.Sub(s.apiCallResetTime) >= time.Minute { @@ -76,6 +76,10 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL)) + if region != "" { + apiURL += fmt.Sprintf("&userCountry=%s", region) + } + req, err := http.NewRequest("GET", apiURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) diff --git a/backend/spotfetch.go b/backend/spotfetch.go index 055c277..992191a 100644 --- a/backend/spotfetch.go +++ b/backend/spotfetch.go @@ -112,7 +112,7 @@ func (c *SpotifyClient) getAccessToken() error { q.Add("totpServer", totpCode) req.URL.RawQuery = q.Encode() - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") req.Header.Set("Content-Type", "application/json;charset=UTF-8") resp, err := c.client.Do(req) @@ -149,7 +149,7 @@ func (c *SpotifyClient) getSessionInfo() error { return err } - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") for name, value := range c.cookies { req.AddCookie(&http.Cookie{Name: name, Value: value}) @@ -230,7 +230,7 @@ func (c *SpotifyClient) getClientToken() error { req.Header.Set("Authority", "clienttoken.spotify.com") req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") resp, err := c.client.Do(req) if err != nil { @@ -288,7 +288,7 @@ func (c *SpotifyClient) Query(payload map[string]interface{}) (map[string]interf req.Header.Set("Client-Token", c.clientToken) req.Header.Set("Spotify-App-Version", c.clientVersion) req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") resp, err := c.client.Do(req) if err != nil { @@ -772,18 +772,22 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter totalDiscs = discInfo["totalDiscs"].(int) } + contentRating := getMap(trackData, "contentRating") + isExplicit := getString(contentRating, "label") == "EXPLICIT" + filtered := map[string]interface{}{ - "id": getString(trackData, "id"), - "name": getString(trackData, "name"), - "artists": artistsString, - "album": albumInfo, - "duration": durationString, - "track": int(getFloat64(trackData, "trackNumber")), - "disc": discNumber, - "discs": totalDiscs, - "copyright": copyrightString, - "plays": getString(trackData, "playcount"), - "cover": cover, + "id": getString(trackData, "id"), + "name": getString(trackData, "name"), + "artists": artistsString, + "album": albumInfo, + "duration": durationString, + "track": int(getFloat64(trackData, "trackNumber")), + "disc": discNumber, + "discs": totalDiscs, + "copyright": copyrightString, + "plays": getString(trackData, "playcount"), + "cover": cover, + "is_explicit": isExplicit, } return filtered @@ -871,13 +875,17 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} { trackID = parts[len(parts)-1] } + contentRating := getMap(track, "contentRating") + isExplicit := getString(contentRating, "label") == "EXPLICIT" + trackInfo := map[string]interface{}{ - "id": trackID, - "name": getString(track, "name"), - "artists": trackArtistsString, - "artistIds": artistIDs, - "duration": durationString, - "plays": getString(track, "playcount"), + "id": trackID, + "name": getString(track, "name"), + "artists": trackArtistsString, + "artistIds": artistIDs, + "duration": durationString, + "plays": getString(track, "playcount"), + "is_explicit": isExplicit, } tracks = append(tracks, trackInfo) } @@ -1092,6 +1100,9 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} { } } + contentRating := getMap(trackData, "contentRating") + isExplicit := getString(contentRating, "label") == "EXPLICIT" + trackInfo := map[string]interface{}{ "id": trackID, "cover": trackCover, @@ -1104,6 +1115,7 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} { "albumArtist": albumArtistsString, "albumId": albumID, "duration": durationString, + "is_explicit": isExplicit, } tracks = append(tracks, trackInfo) } @@ -1197,12 +1209,20 @@ func extractRelease(release map[string]interface{}) map[string]interface{} { year = yearVal } + var totalTracks int + tracksInfo := getMap(release, "tracks") + if tracksInfo != nil { + totalTracks = int(getFloat64(tracksInfo, "totalCount")) + } + return map[string]interface{}{ - "id": releaseID, - "name": getString(release, "name"), - "cover": cover, - "date": releaseDate, - "year": year, + "id": releaseID, + "name": getString(release, "name"), + "cover": cover, + "date": releaseDate, + "year": year, + "total_tracks": totalTracks, + "type": getString(release, "type"), } } @@ -1472,14 +1492,18 @@ func FilterSearch(data map[string]interface{}) map[string]interface{} { albumName = getString(albumInfo, "name") } + contentRating := getMap(track, "contentRating") + isExplicit := getString(contentRating, "label") == "EXPLICIT" + trackResults := results["tracks"].([]map[string]interface{}) trackResults = append(trackResults, map[string]interface{}{ - "id": trackID, - "name": trackName, - "artists": trackArtistsString, - "album": albumName, - "duration": durationString, - "cover": cover, + "id": trackID, + "name": trackName, + "artists": trackArtistsString, + "album": albumName, + "duration": durationString, + "cover": cover, + "is_explicit": isExplicit, }) results["tracks"] = trackResults } diff --git a/backend/spotify_metadata.go b/backend/spotify_metadata.go index ecbeff2..c102fe1 100644 --- a/backend/spotify_metadata.go +++ b/backend/spotify_metadata.go @@ -47,6 +47,7 @@ type TrackMetadata struct { Publisher string `json:"publisher,omitempty"` Plays string `json:"plays,omitempty"` PreviewURL string `json:"preview_url,omitempty"` + IsExplicit bool `json:"is_explicit,omitempty"` } type ArtistSimple struct { @@ -79,6 +80,7 @@ type AlbumTrackMetadata struct { Plays string `json:"plays,omitempty"` Status string `json:"status,omitempty"` PreviewURL string `json:"preview_url,omitempty"` + IsExplicit bool `json:"is_explicit,omitempty"` } type TrackResponse struct { @@ -198,6 +200,7 @@ type apiTrackResponse struct { Medium string `json:"medium"` Large string `json:"large"` } `json:"cover"` + IsExplicit bool `json:"is_explicit"` } type apiAlbumResponse struct { @@ -208,12 +211,13 @@ type apiAlbumResponse struct { ReleaseDate string `json:"releaseDate"` Count int `json:"count"` Tracks []struct { - ID string `json:"id"` - Name string `json:"name"` - Artists string `json:"artists"` - ArtistIds []string `json:"artistIds"` - Duration string `json:"duration"` - Plays string `json:"plays"` + ID string `json:"id"` + Name string `json:"name"` + Artists string `json:"artists"` + ArtistIds []string `json:"artistIds"` + Duration string `json:"duration"` + Plays string `json:"plays"` + IsExplicit bool `json:"is_explicit"` } `json:"tracks"` } @@ -240,6 +244,7 @@ type apiPlaylistResponse struct { AlbumArtist string `json:"albumArtist"` AlbumID string `json:"albumId"` Duration string `json:"duration"` + IsExplicit bool `json:"is_explicit"` } `json:"tracks"` } @@ -261,11 +266,13 @@ type apiArtistResponse struct { Gallery []string `json:"gallery"` Discography struct { All []struct { - ID string `json:"id"` - Name string `json:"name"` - Cover string `json:"cover"` - Date string `json:"date"` - Year int `json:"year"` + ID string `json:"id"` + Name string `json:"name"` + Cover string `json:"cover"` + Date string `json:"date"` + Year int `json:"year"` + TotalTracks int `json:"total_tracks"` + Type string `json:"type"` } `json:"all"` Total int `json:"total"` } `json:"discography"` @@ -274,12 +281,13 @@ type apiArtistResponse struct { type apiSearchResponse struct { Results struct { Tracks []struct { - ID string `json:"id"` - Name string `json:"name"` - Artists string `json:"artists"` - Album string `json:"album"` - Duration string `json:"duration"` - Cover string `json:"cover"` + ID string `json:"id"` + Name string `json:"name"` + Artists string `json:"artists"` + Album string `json:"album"` + Duration string `json:"duration"` + Cover string `json:"cover"` + IsExplicit bool `json:"is_explicit"` } `json:"tracks"` Albums []struct { ID string `json:"id"` @@ -320,6 +328,7 @@ type SearchResult struct { Duration int `json:"duration_ms,omitempty"` TotalTracks int `json:"total_tracks,omitempty"` Owner string `json:"owner,omitempty"` + IsExplicit bool `json:"is_explicit,omitempty"` } type SearchResponse struct { @@ -464,6 +473,10 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID string) if err := client.Initialize(); err != nil { return nil, fmt.Errorf("failed to initialize spotify client: %w", err) } + return c.fetchAlbumWithClient(ctx, client, albumID) +} + +func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client *SpotifyClient, albumID string) (*apiAlbumResponse, error) { allItems := []interface{}{} offset := 0 @@ -727,6 +740,12 @@ func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, pars } offset += limit + + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } } albumsItems := []interface{}{} @@ -843,6 +862,7 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp Copyright: raw.Copyright, Publisher: raw.Album.Label, Plays: raw.Plays, + IsExplicit: raw.IsExplicit, } return TrackResponse{ @@ -904,6 +924,7 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumRe ArtistURL: artistURL, ArtistsData: artistsData, Plays: item.Plays, + IsExplicit: item.IsExplicit, }) } @@ -964,6 +985,7 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla ArtistsData: artistsData, Plays: item.Plays, Status: item.Status, + IsExplicit: item.IsExplicit, }) } @@ -995,79 +1017,105 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, albumList := make([]DiscographyAlbumMetadata, 0, len(raw.Discography.All)) allTracks := make([]AlbumTrackMetadata, 0) + type fetchResult struct { + tracks []AlbumTrackMetadata + err error + } + + resultsChan := make(chan fetchResult, len(raw.Discography.All)) + sem := make(chan struct{}, 5) + + sharedClient := NewSpotifyClient() + if err := sharedClient.Initialize(); err != nil { + return nil, fmt.Errorf("failed to initialize shared spotify client: %w", err) + } + for _, alb := range raw.Discography.All { - - select { - case <-ctx.Done(): - - return &ArtistDiscographyPayload{ - ArtistInfo: info, - AlbumList: albumList, - TrackList: allTracks, - }, ctx.Err() - default: - - } - albumList = append(albumList, DiscographyAlbumMetadata{ ID: alb.ID, Name: alb.Name, - AlbumType: "album", + AlbumType: alb.Type, ReleaseDate: alb.Date, - TotalTracks: 0, + TotalTracks: alb.TotalTracks, Artists: raw.Name, Images: alb.Cover, ExternalURL: fmt.Sprintf("https://open.spotify.com/album/%s", alb.ID), }) - albumData, err := c.fetchAlbum(ctx, alb.ID) - if err != nil { - fmt.Printf("Error getting tracks for album %s: %v\n", alb.Name, err) - continue - } + go func(albumID string, albumName string) { + sem <- struct{}{} - for idx, tr := range albumData.Tracks { - durationMS := parseDuration(tr.Duration) - trackNumber := idx + 1 + time.Sleep(100 * time.Millisecond) + defer func() { <-sem }() - var artistID, artistURL string - if len(tr.ArtistIds) > 0 { - artistID = tr.ArtistIds[0] - artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", artistID) + select { + case <-ctx.Done(): + resultsChan <- fetchResult{err: ctx.Err()} + return + default: } - artistsData := make([]ArtistSimple, 0, len(tr.ArtistIds)) - for _, id := range tr.ArtistIds { - artistsData = append(artistsData, ArtistSimple{ - ID: id, - Name: "", - ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", id), + albumData, err := c.fetchAlbumWithClient(ctx, sharedClient, albumID) + if err != nil { + fmt.Printf("Error getting tracks for album %s: %v\n", albumName, err) + resultsChan <- fetchResult{tracks: []AlbumTrackMetadata{}} + return + } + + tracks := make([]AlbumTrackMetadata, 0, len(albumData.Tracks)) + for idx, tr := range albumData.Tracks { + durationMS := parseDuration(tr.Duration) + trackNumber := idx + 1 + + var artistID, artistURL string + if len(tr.ArtistIds) > 0 { + artistID = tr.ArtistIds[0] + artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", artistID) + } + + artistsData := make([]ArtistSimple, 0, len(tr.ArtistIds)) + for _, id := range tr.ArtistIds { + artistsData = append(artistsData, ArtistSimple{ + ID: id, + Name: "", + ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", id), + }) + } + + tracks = append(tracks, AlbumTrackMetadata{ + SpotifyID: tr.ID, + Artists: tr.Artists, + Name: tr.Name, + AlbumName: albumData.Name, + AlbumArtist: raw.Name, + AlbumType: "album", + DurationMS: durationMS, + Images: albumData.Cover, + ReleaseDate: albumData.ReleaseDate, + TrackNumber: trackNumber, + TotalTracks: albumData.Count, + DiscNumber: 1, + ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", tr.ID), + ISRC: tr.ID, + AlbumID: albumID, + AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", albumID), + ArtistID: artistID, + ArtistURL: artistURL, + ArtistsData: artistsData, + Plays: tr.Plays, + IsExplicit: tr.IsExplicit, }) } + resultsChan <- fetchResult{tracks: tracks} + }(alb.ID, alb.Name) + } - allTracks = append(allTracks, AlbumTrackMetadata{ - SpotifyID: tr.ID, - Artists: tr.Artists, - Name: tr.Name, - AlbumName: albumData.Name, - AlbumArtist: albumData.Artists, - AlbumType: "album", - DurationMS: durationMS, - Images: albumData.Cover, - ReleaseDate: albumData.ReleaseDate, - TrackNumber: trackNumber, - TotalTracks: albumData.Count, - DiscNumber: 1, - ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", tr.ID), - ISRC: tr.ID, - AlbumID: alb.ID, - AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", alb.ID), - ArtistID: artistID, - ArtistURL: artistURL, - ArtistsData: artistsData, - Plays: tr.Plays, - }) + for i := 0; i < len(raw.Discography.All); i++ { + res := <-resultsChan + if res.err != nil { + return nil, res.err } + allTracks = append(allTracks, res.tracks...) } return &ArtistDiscographyPayload{ @@ -1246,6 +1294,7 @@ func (c *SpotifyMetadataClient) Search(ctx context.Context, query string, limit Images: item.Cover, ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID), Duration: parseDuration(item.Duration), + IsExplicit: item.IsExplicit, }) } @@ -1359,6 +1408,7 @@ func (c *SpotifyMetadataClient) SearchByType(ctx context.Context, query string, Images: item.Cover, ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID), Duration: parseDuration(item.Duration), + IsExplicit: item.IsExplicit, }) } case "album": diff --git a/backend/tidal.go b/backend/tidal.go index 1447541..af78d05 100644 --- a/backend/tidal.go +++ b/backend/tidal.go @@ -6,6 +6,7 @@ import ( "encoding/xml" "fmt" "io" + "math/rand" "net/http" "net/url" "os" @@ -17,38 +18,10 @@ import ( ) type TidalDownloader struct { - client *http.Client - timeout time.Duration - maxRetries int - clientID string - clientSecret string - apiURL string -} - -type TidalTrack struct { - ID int64 `json:"id"` - Title string `json:"title"` - ISRC string `json:"isrc"` - AudioQuality string `json:"audioQuality"` - TrackNumber int `json:"trackNumber"` - VolumeNumber int `json:"volumeNumber"` - Duration int `json:"duration"` - Copyright string `json:"copyright"` - Explicit bool `json:"explicit"` - Album struct { - Title string `json:"title"` - Cover string `json:"cover"` - ReleaseDate string `json:"releaseDate"` - } `json:"album"` - Artists []struct { - Name string `json:"name"` - } `json:"artists"` - Artist struct { - Name string `json:"name"` - } `json:"artist"` - MediaMetadata struct { - Tags []string `json:"tags"` - } `json:"mediaMetadata"` + client *http.Client + timeout time.Duration + maxRetries int + apiURL string } type TidalAPIResponse struct { @@ -70,11 +43,6 @@ type TidalAPIResponseV2 struct { } `json:"data"` } -type TidalAPIInfo struct { - URL string `json:"url"` - Status string `json:"status"` -} - type TidalBTSManifest struct { MimeType string `json:"mimeType"` Codecs string `json:"codecs"` @@ -83,19 +51,14 @@ type TidalBTSManifest struct { } func NewTidalDownloader(apiURL string) *TidalDownloader { - clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==") - clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=") - if apiURL == "" { downloader := &TidalDownloader{ client: &http.Client{ Timeout: 5 * time.Second, }, - timeout: 5 * time.Second, - maxRetries: 3, - clientID: string(clientID), - clientSecret: string(clientSecret), - apiURL: "", + timeout: 5 * time.Second, + maxRetries: 3, + apiURL: "", } apis, err := downloader.GetAvailableAPIs() @@ -108,79 +71,30 @@ func NewTidalDownloader(apiURL string) *TidalDownloader { client: &http.Client{ Timeout: 5 * time.Second, }, - timeout: 5 * time.Second, - maxRetries: 3, - clientID: string(clientID), - clientSecret: string(clientSecret), - apiURL: apiURL, + timeout: 5 * time.Second, + maxRetries: 3, + apiURL: apiURL, } } func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) { - - encodedAPIs := []string{ - "dm9nZWwucXFkbC5zaXRl", - "bWF1cy5xcWRsLnNpdGU=", - "aHVuZC5xcWRsLnNpdGU=", - "a2F0emUucXFkbC5zaXRl", - "d29sZi5xcWRsLnNpdGU=", - "dGlkYWwua2lub3BsdXMub25saW5l", - "dGlkYWwtYXBpLmJpbmltdW0ub3Jn", - "dHJpdG9uLnNxdWlkLnd0Zg==", + apis := []string{ + "https://triton.squid.wtf", + "https://hifi-one.spotisaver.net", + "https://hifi-two.spotisaver.net", + "https://tidal.kinoplus.online", + "https://tidal-api.binimum.org", } - - var apis []string - for _, encoded := range encodedAPIs { - decoded, err := base64.StdEncoding.DecodeString(encoded) - if err != nil { - continue - } - apis = append(apis, "https://"+string(decoded)) - } - return apis, nil } -func (t *TidalDownloader) GetAccessToken() (string, error) { - data := fmt.Sprintf("client_id=%s&grant_type=client_credentials", t.clientID) - - authURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hdXRoLnRpZGFsLmNvbS92MS9vYXV0aDIvdG9rZW4=") - req, err := http.NewRequest("POST", string(authURL), strings.NewReader(data)) - if err != nil { - return "", err - } - - req.SetBasicAuth(t.clientID, t.clientSecret) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - resp, err := t.client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return "", fmt.Errorf("failed to get access token: HTTP %d", resp.StatusCode) - } - - var result struct { - AccessToken string `json:"access_token"` - } - - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", err - } - - return result.AccessToken, nil -} - func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) { - spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") - spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) + spotifyBase := "https://open.spotify.com/track/" + spotifyURL := fmt.Sprintf("%s%s", spotifyBase, spotifyTrackID) - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") - apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL)) + apiBase := "https://api.song.link/v1-alpha.1/links?url=" + apiURL := fmt.Sprintf("%s%s", apiBase, url.QueryEscape(spotifyURL)) req, err := http.NewRequest("GET", apiURL, nil) if err != nil { @@ -237,42 +151,6 @@ func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) { return trackID, nil } -func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) { - token, err := t.GetAccessToken() - if err != nil { - return nil, fmt.Errorf("failed to get access token: %w", err) - } - - trackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3RyYWNrcy8=") - trackURL := fmt.Sprintf("%s%d?countryCode=US", string(trackBase), trackID) - - req, err := http.NewRequest("GET", trackURL, nil) - if err != nil { - return nil, err - } - - req.Header.Set("Authorization", "Bearer "+token) - - resp, err := t.client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("failed to get track info: HTTP %d - %s", resp.StatusCode, string(body)) - } - - var trackInfo TidalTrack - if err := json.NewDecoder(resp.Body).Decode(&trackInfo); err != nil { - return nil, err - } - - fmt.Printf("Found: %s (%s)\n", trackInfo.Title, trackInfo.AudioQuality) - return &trackInfo, nil -} - func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) { fmt.Println("Fetching URL...") @@ -330,25 +208,6 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, return "", fmt.Errorf("download URL not found in response") } -func (t *TidalDownloader) DownloadAlbumArt(albumID string) ([]byte, error) { - albumID = strings.ReplaceAll(albumID, "-", "/") - - imageBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9yZXNvdXJjZXMudGlkYWwuY29tL2ltYWdlcy8=") - artURL := fmt.Sprintf("%s%s/1280x1280.jpg", string(imageBase), albumID) - - resp, err := t.client.Get(artURL) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("failed to download album art: HTTP %d", resp.StatusCode) - } - - return io.ReadAll(resp.Body) -} - func (t *TidalDownloader) DownloadFile(url, filepath string) error { if strings.HasPrefix(url, "MANIFEST:") { @@ -528,7 +387,7 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e return nil } -func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) { +func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool) (string, error) { if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { return "", fmt.Errorf("directory error: %w", err) @@ -542,12 +401,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo return "", err } - trackInfo, err := t.GetTrackInfoByID(trackID) - if err != nil { - return "", err - } - - if trackInfo.ID == 0 { + if trackID == 0 { return "", fmt.Errorf("no track ID found") } @@ -560,7 +414,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo albumTitleForFile := sanitizeFilename(albumTitle) albumArtistForFile := sanitizeFilename(spotifyAlbumArtist) - filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, trackInfo.TrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) + filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) outputFilename := filepath.Join(outputDir, filename) if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { @@ -568,9 +422,17 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo return "EXISTS:" + outputFilename, nil } - downloadURL, err := t.GetDownloadURL(trackInfo.ID, quality) + downloadURL, err := t.GetDownloadURL(trackID, quality) if err != nil { - return "", err + if quality == "HI_RES" && allowFallback { + fmt.Println("⚠ HI_RES unavailable/failed, falling back to LOSSLESS...") + downloadURL, err = t.GetDownloadURL(trackID, "LOSSLESS") + if err != nil { + return "", fmt.Errorf("failed to get download URL (HI_RES & LOSSLESS both failed): %w", err) + } + } else { + return "", err + } } fmt.Printf("Downloading to: %s\n", outputFilename) @@ -626,7 +488,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo return outputFilename, nil } -func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) { +func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool) (string, error) { apis, err := t.GetAvailableAPIs() if err != nil { return "", fmt.Errorf("no APIs available for fallback: %w", err) @@ -645,12 +507,7 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality return "", err } - trackInfo, err := t.GetTrackInfoByID(trackID) - if err != nil { - return "", err - } - - if trackInfo.ID == 0 { + if trackID == 0 { return "", fmt.Errorf("no track ID found") } @@ -663,7 +520,7 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality albumTitleForFile := sanitizeFilename(albumTitle) albumArtistForFile := sanitizeFilename(spotifyAlbumArtist) - filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, trackInfo.TrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) + filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) outputFilename := filepath.Join(outputDir, filename) if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { @@ -671,9 +528,17 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality return "EXISTS:" + outputFilename, nil } - successAPI, downloadURL, err := getDownloadURLParallel(apis, trackInfo.ID, quality) + successAPI, downloadURL, err := getDownloadURLRotated(apis, trackID, quality) if err != nil { - return "", err + if quality == "HI_RES" && allowFallback { + fmt.Println("⚠ HI_RES unavailable/failed on all APIs, falling back to LOSSLESS...") + successAPI, downloadURL, err = getDownloadURLRotated(apis, trackID, "LOSSLESS") + if err != nil { + return "", fmt.Errorf("failed to get download URL (HI_RES & LOSSLESS both failed): %w", err) + } + } else { + return "", err + } } fmt.Printf("Downloading to: %s\n", outputFilename) @@ -730,14 +595,14 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality return outputFilename, nil } -func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) { +func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool) (string, error) { tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID) if err != nil { return "", fmt.Errorf("songlink couldn't find Tidal URL: %w", err) } - return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL) + return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback) } type SegmentTemplate struct { @@ -902,89 +767,67 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU return "", initURL, mediaURLs, nil } -type manifestResult struct { - apiURL string - manifest string - err error -} - -func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) { +func getDownloadURLRotated(apis []string, trackID int64, quality string) (string, string, error) { if len(apis) == 0 { return "", "", fmt.Errorf("no APIs available") } - resultChan := make(chan manifestResult, len(apis)) + rand.Seed(time.Now().UnixNano()) + rand.Shuffle(len(apis), func(i, j int) { apis[i], apis[j] = apis[j], apis[i] }) - fmt.Printf("Requesting download URL from %d APIs in parallel...\n", len(apis)) - for _, apiURL := range apis { - go func(api string) { - - client := &http.Client{ - Timeout: 15 * time.Second, - } - - url := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality) - resp, err := client.Get(url) - if err != nil { - resultChan <- manifestResult{apiURL: api, err: err} - return - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - resultChan <- manifestResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode)} - return - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - resultChan <- manifestResult{apiURL: api, err: err} - return - } - - var v2Response TidalAPIResponseV2 - if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" { - resultChan <- manifestResult{apiURL: api, manifest: v2Response.Data.Manifest, err: nil} - return - } - - var v1Responses []TidalAPIResponse - if err := json.Unmarshal(body, &v1Responses); err == nil { - for _, item := range v1Responses { - if item.OriginalTrackURL != "" { - - resultChan <- manifestResult{apiURL: api, manifest: "DIRECT:" + item.OriginalTrackURL, err: nil} - return - } - } - } - - resultChan <- manifestResult{apiURL: api, err: fmt.Errorf("no download URL or manifest in response")} - }(apiURL) - } + fmt.Printf("Rotating through %d APIs...\n", len(apis)) var lastError error var errors []string - for i := 0; i < len(apis); i++ { - result := <-resultChan - if result.err == nil && result.manifest != "" { + for _, apiURL := range apis { + fmt.Printf("Trying API: %s\n", apiURL) - fmt.Printf("✓ Got response from: %s\n", result.apiURL) - - if strings.HasPrefix(result.manifest, "DIRECT:") { - return result.apiURL, strings.TrimPrefix(result.manifest, "DIRECT:"), nil - } - - return result.apiURL, "MANIFEST:" + result.manifest, nil - } else { - errMsg := result.err.Error() - if len(errMsg) > 50 { - errMsg = errMsg[:50] + "..." - } - errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg)) - lastError = result.err + client := &http.Client{ + Timeout: 15 * time.Second, } + + url := fmt.Sprintf("%s/track/?id=%d&quality=%s", apiURL, trackID, quality) + resp, err := client.Get(url) + if err != nil { + lastError = err + errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err)) + continue + } + + if resp.StatusCode != 200 { + resp.Body.Close() + lastError = fmt.Errorf("HTTP %d", resp.StatusCode) + errors = append(errors, fmt.Sprintf("%s: %v", apiURL, lastError)) + continue + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + lastError = err + errors = append(errors, fmt.Sprintf("%s: read body failed", apiURL)) + continue + } + + var v2Response TidalAPIResponseV2 + if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" { + fmt.Printf("✓ Success with: %s\n", apiURL) + return apiURL, "MANIFEST:" + v2Response.Data.Manifest, nil + } + + var v1Responses []TidalAPIResponse + if err := json.Unmarshal(body, &v1Responses); err == nil { + for _, item := range v1Responses { + if item.OriginalTrackURL != "" { + fmt.Printf("✓ Success with: %s\n", apiURL) + return apiURL, item.OriginalTrackURL, nil + } + } + } + + lastError = fmt.Errorf("no download URL or manifest in response") + errors = append(errors, fmt.Sprintf("%s: %v", apiURL, lastError)) } fmt.Println("All APIs failed:") diff --git a/backend/uploader.go b/backend/uploader.go new file mode 100644 index 0000000..4303577 --- /dev/null +++ b/backend/uploader.go @@ -0,0 +1,173 @@ +package backend + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +type SendNowResponse []struct { + FileCode string `json:"file_code"` +} + +func UploadToSendNow(filePath string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", fmt.Errorf("failed to open file: %v", err) + } + defer file.Close() + + return uploadToService(filepath.Base(filePath), file) +} + +func UploadBytesToSendNow(filename string, data []byte) (string, error) { + return uploadToService(filename, bytes.NewReader(data)) +} + +func uploadToService(filename string, fileReader io.Reader) (string, error) { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + fields := map[string]string{ + "sess_id": "", + "utype": "anon", + "hidden": "", + "enableemail": "", + "link_rcpt": "", + "link_pass": "", + "file_expire_time": "", + "file_expire_unit": "DAY", + "file_max_dl": "1", + "file_public": "1", + "keepalive": "1", + } + + for key, val := range fields { + if err := writer.WriteField(key, val); err != nil { + return "", err + } + } + + part, err := writer.CreateFormFile("file_0", filename) + if err != nil { + return "", err + } + if _, err := io.Copy(part, fileReader); err != nil { + return "", err + } + + writer.Close() + + req, err := http.NewRequest("POST", "https://u1112.send.now/cgi-bin/upload.cgi?upload_type=file&utype=anon", body) + if err != nil { + return "", err + } + + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + req.Header.Set("Origin", "https://send.now") + req.Header.Set("Referer", "https://send.now/") + req.Header.Set("Content-Type", writer.FormDataContentType()) + + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("upload failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + respBytes, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("server error %d: %s", resp.StatusCode, string(respBytes)) + } + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + var result SendNowResponse + if err := json.Unmarshal(respBytes, &result); err != nil { + return "", fmt.Errorf("failed to parse response: %v, raw: %s", err, string(respBytes)) + } + + if len(result) == 0 || result[0].FileCode == "" { + return "", fmt.Errorf("invalid response format") + } + + fileCode := result[0].FileCode + downloadLink := fmt.Sprintf("https://send.now/%s", fileCode) + + ext := strings.ToLower(filepath.Ext(filename)) + if ext == ".mp4" || ext == ".mov" || ext == ".mkv" || ext == ".webm" || ext == ".avi" { + return fmt.Sprintf("[Video](%s)", downloadLink), nil + } + + return fetchDirectImageLink(downloadLink) +} + +func fetchDirectImageLink(url string) (string, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + htmlBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + htmlStr := string(htmlBytes) + + reFullRes := regexp.MustCompile(`(?i)]+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=['"] 1 { + return fmt.Sprintf("![image](%s)", matches[1]), nil + } + + reImg := regexp.MustCompile(`(?i)]+src=["']([^"']*?\.send\.now/i/[^"']+)["']`) + matchesImg := reImg.FindStringSubmatch(htmlStr) + if len(matchesImg) > 1 { + return fmt.Sprintf("![image](%s)", matchesImg[1]), nil + } + + reAnchor := regexp.MustCompile(`(?i)]+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)]+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 +} diff --git a/frontend/package.json b/frontend/package.json index ea236b6..eb76449 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.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-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 5e86848..108221f 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -42597f825aff483763c8cb00c83bfa74 \ No newline at end of file +629a5f17426ea4202a25837a341483dd \ No newline at end of file diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 93d63b0..0654fe9 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@radix-ui/react-progress': specifier: ^1.1.8 version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-scroll-area': + specifier: ^1.2.10 + version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-select': specifier: ^2.2.6 version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -855,6 +858,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-scroll-area@1.2.10': + resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-select@2.2.6': resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} peerDependencies: @@ -2725,6 +2741,23 @@ snapshots: '@types/react': 19.2.8 '@types/react-dom': 19.2.3(@types/react@19.2.8) + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/number': 1.1.1 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fa4a20d..21f6776 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,8 +1,6 @@ import { useState, useEffect, useCallback, useLayoutEffect } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; import { Search, X, ArrowUp } from "lucide-react"; import { TooltipProvider } from "@/components/ui/tooltip"; import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont } from "@/lib/settings"; @@ -48,14 +46,18 @@ function App() { const [releaseDate, setReleaseDate] = useState(null); const [fetchHistory, setFetchHistory] = useState([]); const [isSearchMode, setIsSearchMode] = useState(false); + const [region, setRegion] = useState(() => localStorage.getItem("spotiflac_region") || "US"); + useEffect(() => { + localStorage.setItem("spotiflac_region", region); + }, [region]); const [showScrollTop, setShowScrollTop] = useState(false); const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false); const [pendingPageChange, setPendingPageChange] = useState(null); const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false); const [resetSettingsFn, setResetSettingsFn] = useState<(() => void) | null>(null); const ITEMS_PER_PAGE = 50; - const CURRENT_VERSION = "7.0.6"; - const download = useDownload(); + const CURRENT_VERSION = __APP_VERSION__; + const download = useDownload(region); const metadata = useMetadata(); const lyrics = useLyrics(); const cover = useCover(); @@ -293,11 +295,14 @@ function App() { }; const toggleSelectAll = (tracks: any[]) => { const tracksWithIsrc = tracks.filter((track) => track.isrc).map((track) => track.isrc); - if (selectedTracks.length === tracksWithIsrc.length) { - setSelectedTracks([]); + if (tracksWithIsrc.length === 0) + return; + const allSelected = tracksWithIsrc.every(isrc => selectedTracks.includes(isrc)); + if (allSelected) { + setSelectedTracks(prev => prev.filter(isrc => !tracksWithIsrc.includes(isrc))); } else { - setSelectedTracks(tracksWithIsrc); + setSelectedTracks(prev => Array.from(new Set([...prev, ...tracksWithIsrc]))); } }; const handleOpenFolder = async () => { @@ -319,11 +324,11 @@ function App() { return null; if ("track" in metadata.metadata) { const { track } = metadata.metadata; - return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, track.album_name, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, track.album_name, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder}/>); + return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, track.album_name, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, track.album_name, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder} onBack={metadata.resetMetadata}/>); } if ("album_info" in metadata.metadata) { const { album_info, track_list } = metadata.metadata; - return ( 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 ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => { const artistUrl = await metadata.handleArtistClick(artist); if (artistUrl) { setSpotifyUrl(artistUrl); @@ -337,7 +342,7 @@ function App() { } if ("playlist_info" in metadata.metadata) { const { playlist_info, track_list } = metadata.metadata; - return ( 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 ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.owner.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlist_info.owner.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => { const artistUrl = await metadata.handleArtistClick(artist); if (artistUrl) { setSpotifyUrl(artistUrl); @@ -351,7 +356,7 @@ function App() { } if ("artist_info" in metadata.metadata) { const { artist_info, album_list, track_list } = metadata.metadata; - return ( 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 ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => { const artistUrl = await metadata.handleArtistClick(artist); if (artistUrl) { setSpotifyUrl(artistUrl); @@ -400,7 +405,10 @@ function App() { case "about": return ; case "history": - return ; + return { + metadata.loadFromCache(cachedData); + setCurrentPage("main"); + }}/>; case "audio-analysis": return ; case "audio-converter": @@ -412,42 +420,6 @@ function App() {
- - -
- -
- Fetch Artist - - Set timeout for fetching metadata. Longer timeout is recommended for artists - with large discography. - - {metadata.pendingArtistName && (
-

{metadata.pendingArtistName}

-
)} -
-
- - metadata.setTimeoutValue(Number(e.target.value))}/> -

- Default: 60 seconds. For large discographies, try 300-600 seconds (5-10 - minutes). -

-
-
- - - - -
-
@@ -487,7 +459,7 @@ function App() { if (updatedUrl) { setSpotifyUrl(updatedUrl); } - }} history={fetchHistory} onHistorySelect={handleHistorySelect} onHistoryRemove={removeFromHistory} hasResult={!!metadata.metadata} searchMode={isSearchMode} onSearchModeChange={setIsSearchMode}/> + }} history={fetchHistory} onHistorySelect={handleHistorySelect} onHistoryRemove={removeFromHistory} hasResult={!!metadata.metadata} searchMode={isSearchMode} onSearchModeChange={setIsSearchMode} region={region} onRegionChange={setRegion}/> {!isSearchMode && metadata.metadata && renderMetadata()} ); diff --git a/frontend/src/assets/icons/next.svg b/frontend/src/assets/icons/next.svg new file mode 100644 index 0000000..3ac2653 --- /dev/null +++ b/frontend/src/assets/icons/next.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/AboutPage.tsx b/frontend/src/components/AboutPage.tsx index 0da7e58..89da21e 100644 --- a/frontend/src/components/AboutPage.tsx +++ b/frontend/src/components/AboutPage.tsx @@ -3,28 +3,30 @@ import { Button } from "@/components/ui/button"; import { openExternal } from "@/lib/utils"; import { GetOSInfo } from "../../wailsjs/go/main/App"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; -import { Bug, Lightbulb, ExternalLink, Star, GitFork, Clock, Download } from "lucide-react"; +import { Bug, Lightbulb, ExternalLink, Star, GitFork, Clock, Download, CircleHelp, Blocks } from "lucide-react"; import AudioTTSProIcon from "@/assets/audiotts-pro.webp"; import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp"; import XProIcon from "@/assets/x-pro.webp"; import SpotubeDLIcon from "@/assets/icons/spotubedl.svg"; import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg"; import XBatchDLIcon from "@/assets/icons/xbatchdl.svg"; +import SpotiFLACNextIcon from "@/assets/icons/next.svg"; import { langColors } from "@/assets/github-lang-colors"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { DragDropMedia } from "./DragDropTextarea"; interface AboutPageProps { version: string; } export function AboutPage({ version }: AboutPageProps) { const [os, setOs] = useState("Unknown"); const [location, setLocation] = useState("Unknown"); - const [reportType, setReportType] = useState("bug"); + const [activeTab, setActiveTab] = useState<"bug_report" | "feature_request" | "faq" | "projects">("bug_report"); + const [bugType, setBugType] = useState("Track"); const [problem, setProblem] = useState(""); - const [bugType, setBugType] = useState("Track"); const [spotifyUrl, setSpotifyUrl] = useState(""); const [bugContext, setBugContext] = useState(""); const [featureDesc, setFeatureDesc] = useState(""); @@ -88,6 +90,7 @@ export function AboutPage({ version }: AboutPageProps) { } const repos = [ { name: 'SpotiDownloader', owner: 'afkarxyz' }, + { name: 'SpotiFLAC-Next', owner: 'spotiverse' }, { name: 'Twitter-X-Media-Batch-Downloader', owner: 'afkarxyz' } ]; const stats: Record = {}; @@ -167,9 +170,6 @@ export function AboutPage({ version }: AboutPageProps) { a: "This is a false positive. It likely happens because the executable is compressed using UPX. If you are concerned, you can fork the repository and build the software yourself from source." } ]; - const sanitizeForURL = (text: string): string => { - return text.replace(/[()]/g, "").replace(/,/g, " -"); - }; const formatTimeAgo = (dateString: string): string => { const now = new Date(); const updated = new Date(dateString); @@ -199,210 +199,240 @@ export function AboutPage({ version }: AboutPageProps) { return langColors[lang] || '#858585'; }; const handleSubmit = () => { - let title = ""; - let body = ""; - if (reportType === "bug") { - title = `[Bug Report] ${problem ? problem.substring(0, 50) + (problem.length > 50 ? "..." : "") : "Issue"}`; - body = `### [Bug Report] + const title = activeTab === "bug_report" + ? `[Bug Report] ${problem.substring(0, 50)}${problem.length > 50 ? "..." : ""}` + : `[Feature Request] ${featureDesc.substring(0, 50)}${featureDesc.length > 50 ? "..." : ""}`; + let bodyContent = ""; + if (activeTab === "bug_report") { + const contextContent = bugContext.trim() ? bugContext.trim() : "Type here or send screenshot/recording"; + bodyContent = `### [Bug Report] #### Problem -> ${problem || "Type here"} +${problem || "Type here"} #### Type -${bugType || "Track / Album / Playlist / Artist"} +${bugType} #### Spotify URL -> ${spotifyUrl || "Type here"} +${spotifyUrl || "Type here"} #### Additional Context -> ${bugContext || "Type here or send screenshot/recording"} +${contextContent} -#### Version -SpotiFLAC v${version} - -#### OS -${sanitizeForURL(os || "Unknown")} - -#### Location -${location || "Unknown"} -`; +#### Environment +- SpotiFLAC Version: ${version} +- OS: ${os} +- Location: ${location}`; } else { - title = `[Feature Request] ${featureDesc ? featureDesc.substring(0, 50) + (featureDesc.length > 50 ? "..." : "") : "Request"}`; - body = `### [Feature Request] + const contextContent = featureContext.trim() ? featureContext.trim() : "Type here or send screenshot/recording"; + bodyContent = `### [Feature Request] #### Description -> ${featureDesc || "Type here"} +${featureDesc || "Type here"} #### Use Case -> ${useCase || "Type here"} +${useCase || "Type here"} #### Additional Context -> ${featureContext || "Type here or send screenshot/recording"} -`; +${contextContent}`; } - const url = `https://github.com/afkarxyz/SpotiFLAC/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`; + const params = new URLSearchParams({ + title: title, + body: bodyContent + }); + const url = `https://github.com/afkarxyz/SpotiFLAC/issues/new?${params.toString()}`; openExternal(url); }; - return (
-
+ return (
+

About

- - - Report Issue - FAQ - Other Projects - +
+ + + + +
- - - - - - Bug Report - Feature Request - - -
- {reportType === "bug" ? (
+
+ {activeTab === "bug_report" && (
+
+
+
-