diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fd41909..3e09dd5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: env: - GO_VERSION: '1.25.5' + GO_VERSION: '1.26' NODE_VERSION: '24' jobs: diff --git a/README.md b/README.md index 1ad9749..28b826f 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ _If this software is useful and brings you value, consider supporting the project by buying me a coffee. Your support helps keep development going._ -[![Ko-fi](https://img.shields.io/badge/Support%20me%20on%20Ko--fi-72a5f2?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/afkarxyz) +[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/afkarxyz) ## Disclaimer @@ -94,8 +94,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), [jumo-dl](https://jumo-dl.pages.dev/) +[MusicBrainz](https://musicbrainz.org) · [Spotify Lyrics API](https://github.akashrchandran.in/spotify-lyrics-api) · [LRCLIB](https://lrclib.net) · [Song.link](https://song.link) · [hifi-api](https://github.com/binimum/hifi-api) · [dabmusic.xyz](https://dabmusic.xyz) > [!TIP] > diff --git a/app.go b/app.go index 66b4c2f..a95ad92 100644 --- a/app.go +++ b/app.go @@ -9,10 +9,11 @@ import ( "path/filepath" - "spotiflac/backend" "strings" "time" + "github.com/afkarxyz/SpotiFLAC/backend" + "github.com/wailsapp/wails/v2/pkg/runtime" ) @@ -88,6 +89,7 @@ type DownloadRequest struct { PlaylistOwner string `json:"playlist_owner,omitempty"` AllowFallback bool `json:"allow_fallback"` UseFirstArtistOnly bool `json:"use_first_artist_only,omitempty"` + UseSingleGenre bool `json:"use_single_genre,omitempty"` } type DownloadResponse struct { @@ -366,25 +368,25 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { downloader := backend.NewAmazonDownloader() if req.ServiceURL != "" { - filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.UseFirstArtistOnly) + filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre) } else { - filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.UseFirstArtistOnly) + filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre) } 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, req.AllowFallback, req.UseFirstArtistOnly) + filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre) } else { - filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly) + filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre) } } 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, req.AllowFallback, req.UseFirstArtistOnly) + filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre) } else { - filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly) + filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre) } } @@ -397,7 +399,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { if quality == "" { quality = "6" } - filename, err = downloader.DownloadTrackWithISRC(isrc, req.SpotifyID, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly) + filename, err = downloader.DownloadTrackWithISRC(isrc, req.SpotifyID, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre) default: return DownloadResponse{ diff --git a/backend/amazon.go b/backend/amazon.go index 4d79c34..ae0a57b 100644 --- a/backend/amazon.go +++ b/backend/amazon.go @@ -1,7 +1,6 @@ package backend import ( - "encoding/base64" "encoding/json" "fmt" "io" @@ -52,7 +51,7 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") fmt.Println("Getting Amazon URL...") @@ -96,8 +95,7 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin parts := strings.Split(amazonURL, "trackAsin=") if len(parts) > 1 { trackAsin := strings.Split(parts[1], "&")[0] - musicBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9tdXNpYy5hbWF6b24uY29tL3RyYWNrcy8=") - amazonURL = fmt.Sprintf("%s%s?musicTerritory=US", string(musicBase), trackAsin) + amazonURL = fmt.Sprintf("https://music.amazon.com/tracks/%s?musicTerritory=US", trackAsin) } } @@ -113,12 +111,12 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL) } - apiURL := fmt.Sprintf("https://amazon.afkarxyz.fun/api/track/%s", asin) + apiURL := fmt.Sprintf("https://amz.afkarxyz.fun/api/track/%s", asin) req, err := http.NewRequest("GET", apiURL, 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") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin) resp, err := a.client.Do(req) @@ -156,7 +154,7 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st defer out.Close() dlReq, _ := http.NewRequest("GET", downloadURL, nil) - dlReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + dlReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") dlResp, err := a.client.Do(dlReq) if err != nil { @@ -261,7 +259,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality str return a.DownloadFromAfkarXYZ(amazonURL, outputDir, quality) } -func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, useFirstArtistOnly bool) (string, error) { +func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool) (string, error) { if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { @@ -285,9 +283,15 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename } } - isrcChan := make(chan string, 1) + type mbResult struct { + ISRC string + Metadata Metadata + } + + metaChan := make(chan mbResult, 1) if spotifyURL != "" { go func() { + res := mbResult{} var isrc string parts := strings.Split(spotifyURL, "/") if len(parts) > 0 { @@ -299,10 +303,20 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename } } } - isrcChan <- isrc + res.ISRC = isrc + if isrc != "" { + fmt.Println("Fetching MusicBrainz metadata...") + if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre); err == nil { + res.Metadata = fetchedMeta + fmt.Println("✓ MusicBrainz metadata fetched") + } else { + fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err) + } + } + metaChan <- res }() } else { - close(isrcChan) + close(metaChan) } fmt.Printf("Using Amazon URL: %s\n", amazonURL) @@ -313,8 +327,11 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename } var isrc string + var mbMeta Metadata if spotifyURL != "" { - isrc = <-isrcChan + result := <-metaChan + isrc = result.ISRC + mbMeta = result.Metadata } originalFileDir := filepath.Dir(filePath) @@ -428,6 +445,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename Publisher: spotifyPublisher, Description: "https://github.com/afkarxyz/SpotiFLAC", ISRC: isrc, + Genre: mbMeta.Genre, } if err := EmbedMetadataToConvertedFile(filePath, metadata, coverPath); err != nil { @@ -454,7 +472,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename } func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, - useFirstArtistOnly bool, + useFirstArtistOnly bool, useSingleGenre bool, ) (string, error) { amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID) @@ -462,5 +480,5 @@ func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, qualit return "", err } - return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, useFirstArtistOnly) + return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, useFirstArtistOnly, useSingleGenre) } diff --git a/backend/ffmpeg.go b/backend/ffmpeg.go index c356e17..92cadb8 100644 --- a/backend/ffmpeg.go +++ b/backend/ffmpeg.go @@ -3,7 +3,7 @@ package backend import ( "archive/tar" "archive/zip" - "encoding/base64" + "fmt" "io" "net/http" @@ -18,14 +18,6 @@ import ( "github.com/ulikunitz/xz" ) -func decodeBase64(encoded string) (string, error) { - decoded, err := base64.StdEncoding.DecodeString(encoded) - if err != nil { - return "", err - } - return string(decoded), nil -} - func ValidateExecutable(path string) error { cleanedPath := filepath.Clean(path) if cleanedPath == "" { @@ -65,13 +57,6 @@ func ValidateExecutable(path string) error { return nil } -const ( - ffmpegWindowsURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3Qtd2luNjQtZ3BsLnppcA==" - ffmpegLinuxURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3QtbGludXg2NC1ncGwudGFyLnh6" - ffmpegMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS96aXA=" - ffprobeMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS9mZnByb2JlL3ppcA==" -) - func GetFFmpegDir() (string, error) { homeDir, err := os.UserHomeDir() if err != nil { @@ -161,6 +146,11 @@ func IsFFmpegInstalled() (bool, error) { return err == nil, nil } +const ( + ffmpegWindowsURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-windows-amd64.zip" + ffmpegLinuxURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-linux-amd64.tar.xz" +) + func DownloadFFmpeg(progressCallback func(int)) error { SetDownloadProgress(0) @@ -181,54 +171,51 @@ func DownloadFFmpeg(progressCallback func(int)) error { ffmpegInstalled, _ := IsFFmpegInstalled() ffprobeInstalled, _ := IsFFprobeInstalled() - if !ffmpegInstalled && !ffprobeInstalled { + isARM := runtime.GOARCH == "arm64" - ffmpegURL, _ := decodeBase64(ffmpegMacOSURL) - fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL) - if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 50); err != nil { + var macFFmpegURLs []string + var macFFprobeURLs []string + + if isARM { + + macFFmpegURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-macos-arm64.zip"} + macFFprobeURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffprobe-macos-arm64.zip"} + } else { + + macFFmpegURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-macos-intel.zip"} + macFFprobeURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffprobe-macos-intel.zip"} + } + + if !ffmpegInstalled && !ffprobeInstalled { + if err := downloadWithFallback(macFFmpegURLs, ffmpegDir, progressCallback, 0, 50); err != nil { return err } - - ffprobeURL, _ := decodeBase64(ffprobeMacOSURL) - fmt.Printf("[FFmpeg] Downloading ffprobe from: %s\n", ffprobeURL) - if err := downloadAndExtract(ffprobeURL, ffmpegDir, progressCallback, 50, 100); err != nil { - return fmt.Errorf("failed to download ffprobe: %w", err) + if err := downloadWithFallback(macFFprobeURLs, ffmpegDir, progressCallback, 50, 100); err != nil { + return err } } else if !ffmpegInstalled { - - ffmpegURL, _ := decodeBase64(ffmpegMacOSURL) - fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL) - if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 100); err != nil { + if err := downloadWithFallback(macFFmpegURLs, ffmpegDir, progressCallback, 0, 100); err != nil { return err } } else if !ffprobeInstalled { - - ffprobeURL, _ := decodeBase64(ffprobeMacOSURL) - fmt.Printf("[FFmpeg] Downloading ffprobe from: %s\n", ffprobeURL) - if err := downloadAndExtract(ffprobeURL, ffmpegDir, progressCallback, 0, 100); err != nil { - return fmt.Errorf("failed to download ffprobe: %w", err) + if err := downloadWithFallback(macFFprobeURLs, ffmpegDir, progressCallback, 0, 100); err != nil { + return err } } return nil } - var encodedURL string + var url string switch runtime.GOOS { case "windows": - encodedURL = ffmpegWindowsURL + url = ffmpegWindowsURL case "linux": - encodedURL = ffmpegLinuxURL + url = ffmpegLinuxURL default: return fmt.Errorf("unsupported operating system: %s", runtime.GOOS) } - url, err := decodeBase64(encodedURL) - if err != nil { - return fmt.Errorf("failed to decode ffmpeg URL: %w", err) - } - fmt.Printf("[FFmpeg] Downloading from: %s\n", url) - if err := downloadAndExtract(url, ffmpegDir, progressCallback, 0, 100); err != nil { return err } @@ -236,6 +223,20 @@ func DownloadFFmpeg(progressCallback func(int)) error { return nil } +func downloadWithFallback(urls []string, destDir string, progressCallback func(int), start, end int) error { + var lastErr error + for _, url := range urls { + fmt.Printf("[FFmpeg] Trying to download from: %s\n", url) + err := downloadAndExtract(url, destDir, progressCallback, start, end) + if err == nil { + return nil + } + lastErr = err + fmt.Printf("[FFmpeg] Attempt failed: %v\n", err) + } + return fmt.Errorf("all download attempts failed: %w", lastErr) +} + func downloadAndExtract(url, destDir string, progressCallback func(int), progressStart, progressEnd int) error { tmpFile, err := os.CreateTemp("", "ffmpeg-*") @@ -245,7 +246,14 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres defer os.Remove(tmpFile.Name()) defer tmpFile.Close() - resp, err := http.Get(url) + client := &http.Client{} + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + + resp, err := client.Do(req) if err != nil { return fmt.Errorf("failed to download: %w", err) } diff --git a/backend/lyrics.go b/backend/lyrics.go index 4fe542c..264694f 100644 --- a/backend/lyrics.go +++ b/backend/lyrics.go @@ -1,7 +1,6 @@ package backend import ( - "encoding/base64" "encoding/json" "fmt" "io" @@ -38,6 +37,17 @@ type LyricsResponse struct { Lines []LyricsLine `json:"lines"` } +type SpotifyLyricsLine struct { + TimeTag string `json:"timeTag"` + Words string `json:"words"` +} + +type SpotifyLyricsAPIResponse struct { + Error bool `json:"error"` + SyncType string `json:"syncType"` + Lines []SpotifyLyricsLine `json:"lines"` +} + type LyricsDownloadRequest struct { SpotifyID string `json:"spotify_id"` TrackName string `json:"track_name"` @@ -73,9 +83,7 @@ func NewLyricsClient() *LyricsClient { func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string, duration int) (*LyricsResponse, error) { - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9nZXQ/YXJ0aXN0X25hbWU9") - apiURL := fmt.Sprintf("%s%s&track_name=%s", - string(apiBase), + apiURL := fmt.Sprintf("https://lrclib.net/api/get?artist_name=%s&track_name=%s", url.QueryEscape(artistName), url.QueryEscape(trackName)) @@ -167,8 +175,7 @@ func lrcTimestampToMs(timestamp string) int64 { func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) (*LyricsResponse, error) { query := fmt.Sprintf("%s %s", artistName, trackName) - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9zZWFyY2g/cT0=") - apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(query)) + apiURL := fmt.Sprintf("https://lrclib.net/api/search?q=%s", url.QueryEscape(query)) resp, err := c.httpClient.Get(apiURL) if err != nil { @@ -212,6 +219,61 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) return c.convertLRCLibToLyricsResponse(best), nil } +func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) { + if spotifyID == "" { + return nil, fmt.Errorf("spotify ID is empty") + } + + apiURL := fmt.Sprintf("https://spotify-lyrics-api-pi.vercel.app/?trackid=%s&format=lrc", spotifyID) + + resp, err := c.httpClient.Get(apiURL) + if err != nil { + return nil, fmt.Errorf("failed to fetch from Spotify Lyrics API: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read Spotify Lyrics API response: %v", err) + } + + var apiResp SpotifyLyricsAPIResponse + if err := json.Unmarshal(body, &apiResp); err != nil { + return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %v", err) + } + + if apiResp.Error { + return nil, fmt.Errorf("Spotify Lyrics API returned error") + } + + result := &LyricsResponse{ + Error: false, + SyncType: apiResp.SyncType, + Lines: []LyricsLine{}, + } + + for _, line := range apiResp.Lines { + if line.TimeTag == "" && line.Words == "" { + continue + } + ms := lrcTimestampToMs(line.TimeTag) + result.Lines = append(result.Lines, LyricsLine{ + StartTimeMs: fmt.Sprintf("%d", ms), + Words: line.Words, + }) + } + + if len(result.Lines) == 0 { + return nil, fmt.Errorf("Spotify Lyrics API returned empty lines") + } + + return result, nil +} + func simplifyTrackName(name string) string { if idx := strings.Index(name, "("); idx > 0 { @@ -226,7 +288,13 @@ func simplifyTrackName(name string) string { func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, duration int) (*LyricsResponse, string, error) { - resp, err := c.FetchLyricsWithMetadata(trackName, artistName, duration) + resp, err := c.FetchLyricsFromSpotifyAPI(spotifyID) + if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 { + return resp, "Spotify", nil + } + fmt.Printf(" Spotify Lyrics API: %v\n", err) + + resp, err = c.FetchLyricsWithMetadata(trackName, artistName, duration) if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 { return resp, "LRCLIB", nil } diff --git a/backend/metadata.go b/backend/metadata.go index 0cd79cc..55b4c7e 100644 --- a/backend/metadata.go +++ b/backend/metadata.go @@ -32,6 +32,7 @@ type Metadata struct { Lyrics string Description string ISRC string + Genre string } func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error { @@ -91,6 +92,10 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error { _ = cmt.Add("ISRC", metadata.ISRC) } + if metadata.Genre != "" { + _ = cmt.Add("GENRE", metadata.Genre) + } + if metadata.Lyrics != "" { _ = cmt.Add("LYRICS", metadata.Lyrics) } diff --git a/backend/musicbrainz.go b/backend/musicbrainz.go new file mode 100644 index 0000000..2681c4e --- /dev/null +++ b/backend/musicbrainz.go @@ -0,0 +1,150 @@ +package backend + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +var AppVersion = "Unknown" + +const musicBrainzAPIBase = "https://musicbrainz.org/ws/2" + +type MusicBrainzRecordingResponse struct { + Recordings []struct { + ID string `json:"id"` + Title string `json:"title"` + Length int `json:"length"` + Releases []struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + ReleaseGroup struct { + ID string `json:"id"` + Title string `json:"title"` + PrimaryType string `json:"primary-type"` + } `json:"release-group"` + Date string `json:"date"` + Country string `json:"country"` + Media []struct { + Format string `json:"format"` + } `json:"media"` + LabelInfo []struct { + Label struct { + Name string `json:"name"` + } `json:"label"` + } `json:"label-info"` + } `json:"releases"` + ArtistCredit []struct { + Artist struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"artist"` + } `json:"artist-credit"` + Tags []struct { + Count int `json:"count"` + Name string `json:"name"` + } `json:"tags"` + } `json:"recordings"` +} + +func FetchMusicBrainzMetadata(isrc, title, artist, album string, useSingleGenre bool) (Metadata, error) { + var meta Metadata + + if isrc == "" { + return meta, fmt.Errorf("no ISRC provided") + } + + client := &http.Client{ + Timeout: 10 * time.Second, + } + + query := fmt.Sprintf("isrc:%s", isrc) + reqURL := fmt.Sprintf("%s/recording?query=%s&fmt=json&inc=releases+artist-credits+tags+media+release-groups+labels", musicBrainzAPIBase, url.QueryEscape(query)) + + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return meta, err + } + + req.Header.Set("User-Agent", fmt.Sprintf("SpotiFLAC/%s ( support@exyezed.cc )", AppVersion)) + + var resp *http.Response + var lastErr error + + for i := 0; i < 3; i++ { + resp, lastErr = client.Do(req) + if lastErr == nil && resp.StatusCode == http.StatusOK { + break + } + + if resp != nil { + resp.Body.Close() + } + + if i < 2 { + time.Sleep(2 * time.Second) + } + } + + if lastErr != nil { + return meta, lastErr + } + + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return meta, fmt.Errorf("MusicBrainz API returned status: %d", resp.StatusCode) + } + defer resp.Body.Close() + + var mbResp MusicBrainzRecordingResponse + if err := json.NewDecoder(resp.Body).Decode(&mbResp); err != nil { + return meta, err + } + + if len(mbResp.Recordings) == 0 { + return meta, fmt.Errorf("no recordings found for ISRC: %s", isrc) + } + + recording := mbResp.Recordings[0] + + var genres []string + caser := cases.Title(language.English) + + if useSingleGenre { + + maxCount := -1 + var bestTag string + + for _, tag := range recording.Tags { + if tag.Count > maxCount { + maxCount = tag.Count + bestTag = tag.Name + } + } + + if bestTag != "" { + meta.Genre = caser.String(bestTag) + } + } else { + for _, tag := range recording.Tags { + + genres = append(genres, caser.String(tag.Name)) + } + if len(genres) > 0 { + + if len(genres) > 5 { + genres = genres[:5] + } + meta.Genre = strings.Join(genres, "; ") + } + } + + return meta, nil +} diff --git a/backend/qobuz.go b/backend/qobuz.go index a684d25..7aaed22 100644 --- a/backend/qobuz.go +++ b/backend/qobuz.go @@ -118,79 +118,6 @@ func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) { return &searchResp.Tracks.Items[0], nil } -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) -} - -func (q *QobuzDownloader) mapJumoQuality(quality string) int { - switch quality { - case "6": - return 6 - case "7": - return 7 - case "27": - return 27 - default: - return 6 - } -} - -func (q *QobuzDownloader) DownloadFromJumo(trackID int64, quality string) (string, error) { - formatID := q.mapJumoQuality(quality) - region := "US" - url := fmt.Sprintf("https://jumo-dl.pages.dev/get?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region) - - client := &http.Client{Timeout: 30 * time.Second} - - 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") - req.Header.Set("Referer", "https://jumo-dl.pages.dev/") - - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return "", fmt.Errorf("HTTP %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - - var result struct { - URL string `json:"url"` - } - - if err := json.Unmarshal(body, &result); err != nil { - - decoded := decodeXOR(body) - if err := json.Unmarshal([]byte(decoded), &result); err != nil { - return "", fmt.Errorf("failed to parse JSON (plain or XOR): %w", err) - } - } - - if result.URL != "" { - return result.URL, nil - } - - return "", fmt.Errorf("URL not found in Jumo response") -} - func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, quality string) (string, error) { apiURL := fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality) resp, err := q.client.Get(apiURL) @@ -261,13 +188,6 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal }) } - 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] }) @@ -433,7 +353,7 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t return filename + ".flac" } -func (q *QobuzDownloader) DownloadTrack(spotifyID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool) (string, error) { +func (q *QobuzDownloader) DownloadTrack(spotifyID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool) (string, error) { var deezerISRC string if spotifyID != "" { songlinkClient := NewSongLinkClient() @@ -446,12 +366,28 @@ func (q *QobuzDownloader) DownloadTrack(spotifyID, outputDir, quality, filenameF return "", fmt.Errorf("spotify ID is required for Qobuz download") } - return q.DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly) + return q.DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre) } -func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool) (string, error) { +func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool) (string, error) { fmt.Printf("Fetching track info for ISRC: %s\n", deezerISRC) + metaChan := make(chan Metadata, 1) + if deezerISRC != "" { + go func() { + fmt.Println("Fetching MusicBrainz metadata...") + if fetchedMeta, err := FetchMusicBrainzMetadata(deezerISRC, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre); err == nil { + fmt.Println("✓ MusicBrainz metadata fetched") + metaChan <- fetchedMeta + } else { + fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err) + metaChan <- Metadata{} + } + }() + } else { + close(metaChan) + } + if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { return "", fmt.Errorf("failed to create output directory: %w", err) @@ -532,6 +468,11 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir } } + var mbMeta Metadata + if deezerISRC != "" { + mbMeta = <-metaChan + } + fmt.Println("Embedding metadata and cover art...") trackNumberToEmbed := spotifyTrackNumber @@ -554,6 +495,7 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir Publisher: spotifyPublisher, Description: "https://github.com/afkarxyz/SpotiFLAC", ISRC: deezerISRC, + Genre: mbMeta.Genre, } if err := EmbedMetadata(filepath, metadata, coverPath); err != nil { diff --git a/backend/songlink.go b/backend/songlink.go index 525f053..dced539 100644 --- a/backend/songlink.go +++ b/backend/songlink.go @@ -1,7 +1,6 @@ package backend import ( - "encoding/base64" "encoding/json" "fmt" "io" @@ -71,11 +70,9 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string, region str } } - spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") - spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) + spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID) - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") - apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL)) + apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(spotifyURL)) if region != "" { apiURL += fmt.Sprintf("&userCountry=%s", region) @@ -200,11 +197,9 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string) (*TrackAv } } - spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") - spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) + spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID) - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") - apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL)) + apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(spotifyURL)) req, err := http.NewRequest("GET", apiURL, nil) if err != nil { @@ -299,8 +294,7 @@ func checkQobuzAvailability(isrc string) bool { client := &http.Client{Timeout: 10 * time.Second} appID := "798273057" - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") - searchURL := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, appID) + searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/track/search?query=%s&limit=1&app_id=%s", isrc, appID) resp, err := client.Get(searchURL) if err != nil { @@ -352,11 +346,9 @@ func (s *SongLinkClient) GetDeezerURLFromSpotify(spotifyTrackID string) (string, } } - spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") - spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) + spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID) - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") - apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL)) + apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(spotifyURL)) req, err := http.NewRequest("GET", apiURL, nil) if err != nil { diff --git a/backend/spotfetch.go b/backend/spotfetch.go index 20f16d7..404ce76 100644 --- a/backend/spotfetch.go +++ b/backend/spotfetch.go @@ -2,9 +2,7 @@ package backend import ( "bytes" - "encoding/base32" "encoding/base64" - "encoding/hex" "encoding/json" "errors" "fmt" @@ -41,39 +39,10 @@ func NewSpotifyClient() *SpotifyClient { } } -func (c *SpotifyClient) getTOTPSecret() (int, []byte) { - secrets := map[int][]byte{ - 59: {123, 105, 79, 70, 110, 59, 52, 125, 60, 49, 80, 70, 89, 75, 80, 86, 63, 53, 123, 37, 117, 49, 52, 93, 77, 62, 47, 86, 48, 104, 68, 72}, - 60: {79, 109, 69, 123, 90, 65, 46, 74, 94, 34, 58, 48, 70, 71, 92, 85, 122, 63, 91, 64, 87, 87}, - 61: {44, 55, 47, 42, 70, 40, 34, 114, 76, 74, 50, 111, 120, 97, 75, 76, 94, 102, 43, 69, 49, 120, 118, 80, 64, 78}, - } - - version := 61 - secretList := secrets[version] - return version, secretList -} - func (c *SpotifyClient) generateTOTP() (string, int, error) { - version, secretList := c.getTOTPSecret() - transformed := make([]byte, len(secretList)) - for i, b := range secretList { - transformed[i] = b ^ byte((i%33)+9) - } - - var joined strings.Builder - for _, b := range transformed { - joined.WriteString(strconv.Itoa(int(b))) - } - - hexStr := hex.EncodeToString([]byte(joined.String())) - hexBytes, err := hex.DecodeString(hexStr) - if err != nil { - return "", 0, err - } - - secret := base32Encode(hexBytes) - secret = strings.TrimRight(secret, "=") + secret := "GM3TMMJTGYZTQNZVGM4DINJZHA4TGOBYGMZTCMRTGEYDSMJRHE4TEOBUG4YTCMRUGQ4DQOJUGQYTAMRRGA2TCMJSHE3TCMBY" + version := 61 key, err := otp.NewKeyFromURL(fmt.Sprintf("otpauth://totp/secret?secret=%s", secret)) if err != nil { @@ -88,11 +57,6 @@ func (c *SpotifyClient) generateTOTP() (string, int, error) { return totpCode, version, nil } -func base32Encode(data []byte) string { - b32 := base32.StdEncoding.WithPadding(base32.NoPadding) - return b32.EncodeToString(data) -} - func (c *SpotifyClient) getAccessToken() error { totpCode, version, err := c.generateTOTP() if err != nil { @@ -112,7 +76,7 @@ func (c *SpotifyClient) getAccessToken() error { q.Add("totpServer", totpCode) req.URL.RawQuery = q.Encode() - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") req.Header.Set("Content-Type", "application/json;charset=UTF-8") resp, err := c.client.Do(req) @@ -149,7 +113,7 @@ func (c *SpotifyClient) getSessionInfo() error { return err } - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") for name, value := range c.cookies { req.AddCookie(&http.Cookie{Name: name, Value: value}) @@ -230,7 +194,7 @@ func (c *SpotifyClient) getClientToken() error { req.Header.Set("Authority", "clienttoken.spotify.com") req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") resp, err := c.client.Do(req) if err != nil { @@ -288,7 +252,7 @@ func (c *SpotifyClient) Query(payload map[string]interface{}) (map[string]interf req.Header.Set("Client-Token", c.clientToken) req.Header.Set("Spotify-App-Version", c.clientVersion) req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") resp, err := c.client.Do(req) if err != nil { diff --git a/backend/tidal.go b/backend/tidal.go index b4d043e..f19e6c8 100644 --- a/backend/tidal.go +++ b/backend/tidal.go @@ -79,6 +79,8 @@ func NewTidalDownloader(apiURL string) *TidalDownloader { func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) { apis := []string{ + "https://api.monochrome.tf", + "https://arran.monochrome.tf", "https://triton.squid.wtf", "https://hifi-one.spotisaver.net", "https://hifi-two.spotisaver.net", @@ -101,7 +103,7 @@ func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, return "", fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") fmt.Println("Getting Tidal URL...") @@ -165,7 +167,7 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, return "", fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") resp, err := t.client.Do(req) if err != nil { @@ -229,7 +231,7 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error { return fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") resp, err := t.client.Do(req) @@ -275,7 +277,7 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e if err != nil { return nil, err } - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") return client.Do(req) } @@ -446,7 +448,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, allowFallback bool, useFirstArtistOnly bool) (string, error) { +func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool) (string, error) { if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { return "", fmt.Errorf("directory error: %w", err) @@ -500,9 +502,15 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo } } - isrcChan := make(chan string, 1) + type mbResult struct { + ISRC string + Metadata Metadata + } + + metaChan := make(chan mbResult, 1) if spotifyURL != "" { go func() { + res := mbResult{} var isrc string parts := strings.Split(spotifyURL, "/") if len(parts) > 0 { @@ -514,10 +522,20 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo } } } - isrcChan <- isrc + res.ISRC = isrc + if isrc != "" { + fmt.Println("Fetching MusicBrainz metadata...") + if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre); err == nil { + res.Metadata = fetchedMeta + fmt.Println("✓ MusicBrainz metadata fetched") + } else { + fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err) + } + } + metaChan <- res }() } else { - close(isrcChan) + close(metaChan) } fmt.Printf("Downloading to: %s\n", outputFilename) @@ -526,8 +544,11 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo } var isrc string + var mbMeta Metadata if spotifyURL != "" { - isrc = <-isrcChan + result := <-metaChan + isrc = result.ISRC + mbMeta = result.Metadata } fmt.Println("Adding metadata...") @@ -566,6 +587,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo Publisher: spotifyPublisher, Description: "https://github.com/afkarxyz/SpotiFLAC", ISRC: isrc, + Genre: mbMeta.Genre, } if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil { @@ -579,7 +601,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, allowFallback bool, useFirstArtistOnly bool) (string, error) { +func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool) (string, error) { apis, err := t.GetAvailableAPIs() if err != nil { return "", fmt.Errorf("no APIs available for fallback: %w", err) @@ -638,9 +660,15 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality } } - isrcChan := make(chan string, 1) + type mbResultFallback struct { + ISRC string + Metadata Metadata + } + + metaChan := make(chan mbResultFallback, 1) if spotifyURL != "" { go func() { + res := mbResultFallback{} var isrc string parts := strings.Split(spotifyURL, "/") if len(parts) > 0 { @@ -652,10 +680,20 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality } } } - isrcChan <- isrc + res.ISRC = isrc + if isrc != "" { + fmt.Println("Fetching MusicBrainz metadata...") + if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre); err == nil { + res.Metadata = fetchedMeta + fmt.Println("✓ MusicBrainz metadata fetched") + } else { + fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err) + } + } + metaChan <- res }() } else { - close(isrcChan) + close(metaChan) } fmt.Printf("Downloading to: %s\n", outputFilename) @@ -665,8 +703,11 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality } var isrc string + var mbMeta Metadata if spotifyURL != "" { - isrc = <-isrcChan + result := <-metaChan + isrc = result.ISRC + mbMeta = result.Metadata } fmt.Println("Adding metadata...") @@ -705,6 +746,7 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality Publisher: spotifyPublisher, Description: "https://github.com/afkarxyz/SpotiFLAC", ISRC: isrc, + Genre: mbMeta.Genre, } if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil { @@ -718,14 +760,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, allowFallback bool, useFirstArtistOnly bool) (string, error) { +func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool) (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, allowFallback, useFirstArtistOnly) + return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre) } type SegmentTemplate struct { diff --git a/backend/uploader.go b/backend/uploader.go index 2f14d10..5f200ca 100644 --- a/backend/uploader.go +++ b/backend/uploader.go @@ -76,7 +76,7 @@ func uploadToService(filename string, fileReader io.Reader) (string, error) { 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("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") req.Header.Set("Origin", "https://send.now") req.Header.Set("Referer", "https://send.now/") req.Header.Set("Content-Type", writer.FormDataContentType()) @@ -123,7 +123,7 @@ func getUploadURL() (string, error) { 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("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) @@ -162,7 +162,7 @@ func fetchDirectImageLink(url string) (string, error) { 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("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) diff --git a/frontend/src/assets/bmc-logo-side-white.svg b/frontend/src/assets/bmc-logo-side-white.svg deleted file mode 100644 index 197f1de..0000000 --- a/frontend/src/assets/bmc-logo-side-white.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/frontend/src/assets/bmc-logo-side.svg b/frontend/src/assets/bmc-logo-side.svg deleted file mode 100644 index 764be24..0000000 --- a/frontend/src/assets/bmc-logo-side.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/frontend/src/assets/bmc-logo.svg b/frontend/src/assets/bmc-logo.svg deleted file mode 100644 index 7963395..0000000 --- a/frontend/src/assets/bmc-logo.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/frontend/src/components/AboutPage.tsx b/frontend/src/components/AboutPage.tsx index e5cac33..ee75a7f 100644 --- a/frontend/src/components/AboutPage.tsx +++ b/frontend/src/components/AboutPage.tsx @@ -2,12 +2,12 @@ import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { openExternal } from "@/lib/utils"; import { GetOSInfo } from "../../wailsjs/go/main/App"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; -import { Bug, Lightbulb, ExternalLink, Star, GitFork, Clock, Download, CircleHelp, Blocks, Heart } from "lucide-react"; +import { Bug, Lightbulb, ExternalLink, Star, GitFork, Clock, Download, CircleHelp, Blocks, Heart, } from "lucide-react"; import AudioTTSProIcon from "@/assets/audiotts-pro.webp"; import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp"; import XProIcon from "@/assets/x-pro.webp"; @@ -15,7 +15,6 @@ import SpotubeDLIcon from "@/assets/icons/spotubedl.svg"; import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg"; import XBatchDLIcon from "@/assets/icons/xbatchdl.svg"; import SpotiFLACNextIcon from "@/assets/icons/next.svg"; -import BmcLogo from "@/assets/bmc-logo.svg"; import KofiLogo from "@/assets/kofi_symbol.svg"; import { langColors } from "@/assets/github-lang-colors"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -54,14 +53,14 @@ export function AboutPage({ version }: AboutPageProps) { fetchOS(); const fetchLocation = async () => { try { - const response = await fetch('https://ipapi.co/json/'); + const response = await fetch("https://ipapi.co/json/"); if (response.ok) { const data = await response.json(); - const city = data.city || ''; - const region = data.region || ''; - const country = data.country_name || ''; + const city = data.city || ""; + const region = data.region || ""; + const country = data.country_name || ""; const parts = [city, region, country].filter(Boolean); - setLocation(parts.join(', ') || 'Unknown'); + setLocation(parts.join(", ") || "Unknown"); } else { const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; @@ -75,7 +74,7 @@ export function AboutPage({ version }: AboutPageProps) { }; fetchLocation(); const fetchRepoStats = async () => { - const CACHE_KEY = 'github_repo_stats'; + const CACHE_KEY = "github_repo_stats"; const CACHE_DURATION = 1000 * 60 * 60; const cached = localStorage.getItem(CACHE_KEY); if (cached) { @@ -87,13 +86,13 @@ export function AboutPage({ version }: AboutPageProps) { } } catch (err) { - console.error('Failed to parse cache:', err); + console.error("Failed to parse cache:", err); } } const repos = [ - { name: 'SpotiDownloader', owner: 'afkarxyz' }, - { name: 'SpotiFLAC-Next', owner: 'spotiverse' }, - { name: 'Twitter-X-Media-Batch-Downloader', owner: 'afkarxyz' } + { name: "SpotiDownloader", owner: "afkarxyz" }, + { name: "SpotiFLAC-Next", owner: "spotiverse" }, + { name: "Twitter-X-Media-Batch-Downloader", owner: "afkarxyz" }, ]; const stats: Record = {}; for (const repo of repos) { @@ -101,7 +100,7 @@ export function AboutPage({ version }: AboutPageProps) { const [repoRes, releasesRes, langsRes] = await Promise.all([ fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}`), fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/releases`), - fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/languages`) + fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/languages`), ]); if (repoRes.status === 403) { if (cached) { @@ -117,9 +116,11 @@ export function AboutPage({ version }: AboutPageProps) { let totalDownloads = 0; let latestDownloads = 0; if (releases.length > 0) { - latestDownloads = releases[0].assets?.reduce((sum: number, asset: any) => sum + (asset.download_count || 0), 0) || 0; + latestDownloads = + releases[0].assets?.reduce((sum: number, asset: any) => sum + (asset.download_count || 0), 0) || 0; totalDownloads = releases.reduce((sum: number, release: any) => { - return sum + (release.assets?.reduce((s: number, a: any) => s + (a.download_count || 0), 0) || 0); + return (sum + + (release.assets?.reduce((s: number, a: any) => s + (a.download_count || 0), 0) || 0)); }, 0); } const topLangs = Object.entries(languages) @@ -132,7 +133,7 @@ export function AboutPage({ version }: AboutPageProps) { createdAt: repoData.created_at, totalDownloads, latestDownloads, - languages: topLangs + languages: topLangs, }; } } @@ -153,24 +154,24 @@ export function AboutPage({ version }: AboutPageProps) { const faqs = [ { q: "Is this software free?", - a: "Yes. This software is completely free. You do not need an account, login, or subscription. All you need is an internet connection." + a: "Yes. This software is completely free. You do not need an account, login, or subscription. All you need is an internet connection.", }, { q: "Can using this software get my Spotify account suspended or banned?", - a: "No. This software has no connection to your Spotify account. Spotify data is obtained through reverse engineering of the Spotify Web Player, not through user authentication." + a: "No. This software has no connection to your Spotify account. Spotify data is obtained through reverse engineering of the Spotify Web Player, not through user authentication.", }, { q: "Where does the audio come from?", - a: "The audio is fetched using third-party APIs." + a: "The audio is fetched using third-party APIs.", }, { q: "Why does metadata fetching sometimes fail?", - a: "This usually happens because your IP address has been rate-limited. You can wait and try again later, or use a VPN to bypass the rate limit." + a: "This usually happens because your IP address has been rate-limited. You can wait and try again later, or use a VPN to bypass the rate limit.", }, { q: "Why does Windows Defender or antivirus flag or delete the file?", - a: "This is a false positive. It likely happens because the executable is compressed using UPX. If you are concerned, you can fork the repository and build the software yourself from source." - } + a: "This is a false positive. It likely happens because the executable is compressed using UPX. If you are concerned, you can fork the repository and build the software yourself from source.", + }, ]; const formatTimeAgo = (dateString: string): string => { const now = new Date(); @@ -179,13 +180,13 @@ export function AboutPage({ version }: AboutPageProps) { const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); const diffMonths = Math.floor(diffDays / 30); if (diffDays === 0) - return 'today'; + return "today"; if (diffDays === 1) - return '1d'; + return "1d"; if (diffDays < 30) return `${diffDays}d`; if (diffMonths === 1) - return '1mo'; + return "1mo"; if (diffMonths < 12) return `${diffMonths}mo`; const diffYears = Math.floor(diffMonths / 12); @@ -198,7 +199,7 @@ export function AboutPage({ version }: AboutPageProps) { return num.toString(); }; const getLangColor = (lang: string): string => { - return langColors[lang] || '#858585'; + return langColors[lang] || "#858585"; }; const handleSubmit = () => { const title = activeTab === "bug_report" @@ -206,7 +207,9 @@ export function AboutPage({ version }: AboutPageProps) { : `[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"; + const contextContent = bugContext.trim() + ? bugContext.trim() + : "Type here or send screenshot/recording"; bodyContent = `### [Bug Report] #### Problem @@ -227,7 +230,9 @@ ${contextContent} - Location: ${location}`; } else { - const contextContent = featureContext.trim() ? featureContext.trim() : "Type here or send screenshot/recording"; + const contextContent = featureContext.trim() + ? featureContext.trim() + : "Type here or send screenshot/recording"; bodyContent = `### [Feature Request] #### Description @@ -241,226 +246,320 @@ ${contextContent}`; } const params = new URLSearchParams({ title: title, - body: bodyContent + body: bodyContent, }); const url = `https://github.com/afkarxyz/SpotiFLAC/issues/new?${params.toString()}`; openExternal(url); }; return (
-
-

About

-
+
+

About

+
-
- - - - - -
+
+ + + + + +
-
- {activeTab === "bug_report" && (
-
-
-
-
- -