This commit is contained in:
afkarxyz
2026-02-24 18:42:22 +07:00
parent 1314c14c59
commit 9ef24f5a91
26 changed files with 904 additions and 635 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ on:
workflow_dispatch: workflow_dispatch:
env: env:
GO_VERSION: '1.25.5' GO_VERSION: '1.26'
NODE_VERSION: '24' NODE_VERSION: '24'
jobs: jobs:
+2 -3
View File
@@ -76,7 +76,7 @@ _If this software is useful and brings you value,
consider supporting the project by buying me a coffee. consider supporting the project by buying me a coffee.
Your support helps keep development going._ 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 ## Disclaimer
@@ -94,8 +94,7 @@ The software is provided "as is", without warranty of any kind. The author assum
## API Credits ## API Credits
- **Tidal**: [hifi-api](https://github.com/binimum/hifi-api) [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)
- **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf), [jumo-dl](https://jumo-dl.pages.dev/)
> [!TIP] > [!TIP]
> >
+10 -8
View File
@@ -9,10 +9,11 @@ import (
"path/filepath" "path/filepath"
"spotiflac/backend"
"strings" "strings"
"time" "time"
"github.com/afkarxyz/SpotiFLAC/backend"
"github.com/wailsapp/wails/v2/pkg/runtime" "github.com/wailsapp/wails/v2/pkg/runtime"
) )
@@ -88,6 +89,7 @@ type DownloadRequest struct {
PlaylistOwner string `json:"playlist_owner,omitempty"` PlaylistOwner string `json:"playlist_owner,omitempty"`
AllowFallback bool `json:"allow_fallback"` AllowFallback bool `json:"allow_fallback"`
UseFirstArtistOnly bool `json:"use_first_artist_only,omitempty"` UseFirstArtistOnly bool `json:"use_first_artist_only,omitempty"`
UseSingleGenre bool `json:"use_single_genre,omitempty"`
} }
type DownloadResponse struct { type DownloadResponse struct {
@@ -366,25 +368,25 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
downloader := backend.NewAmazonDownloader() downloader := backend.NewAmazonDownloader()
if req.ServiceURL != "" { if req.ServiceURL != "" {
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.UseFirstArtistOnly) 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 { } 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": case "tidal":
if req.ApiURL == "" || req.ApiURL == "auto" { if req.ApiURL == "" || req.ApiURL == "auto" {
downloader := backend.NewTidalDownloader("") downloader := backend.NewTidalDownloader("")
if req.ServiceURL != "" { if req.ServiceURL != "" {
filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly) 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 { } 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 { } else {
downloader := backend.NewTidalDownloader(req.ApiURL) downloader := backend.NewTidalDownloader(req.ApiURL)
if req.ServiceURL != "" { if req.ServiceURL != "" {
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly) 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 { } 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 == "" { if quality == "" {
quality = "6" quality = "6"
} }
filename, err = downloader.DownloadTrackWithISRC(isrc, req.SpotifyID, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly) 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: default:
return DownloadResponse{ return DownloadResponse{
+32 -14
View File
@@ -1,7 +1,6 @@
package backend package backend
import ( import (
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -52,7 +51,7 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
if err != nil { if err != nil {
return "", fmt.Errorf("failed to create request: %w", err) return "", fmt.Errorf("failed to create request: %w", err)
} }
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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...") fmt.Println("Getting Amazon URL...")
@@ -96,8 +95,7 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
parts := strings.Split(amazonURL, "trackAsin=") parts := strings.Split(amazonURL, "trackAsin=")
if len(parts) > 1 { if len(parts) > 1 {
trackAsin := strings.Split(parts[1], "&")[0] trackAsin := strings.Split(parts[1], "&")[0]
musicBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9tdXNpYy5hbWF6b24uY29tL3RyYWNrcy8=") amazonURL = fmt.Sprintf("https://music.amazon.com/tracks/%s?musicTerritory=US", trackAsin)
amazonURL = fmt.Sprintf("%s%s?musicTerritory=US", string(musicBase), trackAsin)
} }
} }
@@ -113,12 +111,12 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL) 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) req, err := http.NewRequest("GET", apiURL, nil)
if err != nil { if err != nil {
return "", err return "", err
} }
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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) fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin)
resp, err := a.client.Do(req) resp, err := a.client.Do(req)
@@ -156,7 +154,7 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
defer out.Close() defer out.Close()
dlReq, _ := http.NewRequest("GET", downloadURL, nil) 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) dlResp, err := a.client.Do(dlReq)
if err != nil { if err != nil {
@@ -261,7 +259,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality str
return a.DownloadFromAfkarXYZ(amazonURL, outputDir, quality) 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 outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil { 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 != "" { if spotifyURL != "" {
go func() { go func() {
res := mbResult{}
var isrc string var isrc string
parts := strings.Split(spotifyURL, "/") parts := strings.Split(spotifyURL, "/")
if len(parts) > 0 { 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 { } else {
close(isrcChan) close(metaChan)
} }
fmt.Printf("Using Amazon URL: %s\n", amazonURL) fmt.Printf("Using Amazon URL: %s\n", amazonURL)
@@ -313,8 +327,11 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
} }
var isrc string var isrc string
var mbMeta Metadata
if spotifyURL != "" { if spotifyURL != "" {
isrc = <-isrcChan result := <-metaChan
isrc = result.ISRC
mbMeta = result.Metadata
} }
originalFileDir := filepath.Dir(filePath) originalFileDir := filepath.Dir(filePath)
@@ -428,6 +445,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
Publisher: spotifyPublisher, Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC", Description: "https://github.com/afkarxyz/SpotiFLAC",
ISRC: isrc, ISRC: isrc,
Genre: mbMeta.Genre,
} }
if err := EmbedMetadataToConvertedFile(filePath, metadata, coverPath); err != nil { 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, 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) { ) (string, error) {
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID) amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
@@ -462,5 +480,5 @@ func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, qualit
return "", err 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)
} }
+52 -44
View File
@@ -3,7 +3,7 @@ package backend
import ( import (
"archive/tar" "archive/tar"
"archive/zip" "archive/zip"
"encoding/base64"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -18,14 +18,6 @@ import (
"github.com/ulikunitz/xz" "github.com/ulikunitz/xz"
) )
func decodeBase64(encoded string) (string, error) {
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", err
}
return string(decoded), nil
}
func ValidateExecutable(path string) error { func ValidateExecutable(path string) error {
cleanedPath := filepath.Clean(path) cleanedPath := filepath.Clean(path)
if cleanedPath == "" { if cleanedPath == "" {
@@ -65,13 +57,6 @@ func ValidateExecutable(path string) error {
return nil return nil
} }
const (
ffmpegWindowsURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3Qtd2luNjQtZ3BsLnppcA=="
ffmpegLinuxURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3QtbGludXg2NC1ncGwudGFyLnh6"
ffmpegMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS96aXA="
ffprobeMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS9mZnByb2JlL3ppcA=="
)
func GetFFmpegDir() (string, error) { func GetFFmpegDir() (string, error) {
homeDir, err := os.UserHomeDir() homeDir, err := os.UserHomeDir()
if err != nil { if err != nil {
@@ -161,6 +146,11 @@ func IsFFmpegInstalled() (bool, error) {
return err == nil, nil return err == nil, nil
} }
const (
ffmpegWindowsURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-windows-amd64.zip"
ffmpegLinuxURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-linux-amd64.tar.xz"
)
func DownloadFFmpeg(progressCallback func(int)) error { func DownloadFFmpeg(progressCallback func(int)) error {
SetDownloadProgress(0) SetDownloadProgress(0)
@@ -181,54 +171,51 @@ func DownloadFFmpeg(progressCallback func(int)) error {
ffmpegInstalled, _ := IsFFmpegInstalled() ffmpegInstalled, _ := IsFFmpegInstalled()
ffprobeInstalled, _ := IsFFprobeInstalled() ffprobeInstalled, _ := IsFFprobeInstalled()
if !ffmpegInstalled && !ffprobeInstalled { isARM := runtime.GOARCH == "arm64"
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL) var macFFmpegURLs []string
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL) var macFFprobeURLs []string
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 50); err != nil {
if isARM {
macFFmpegURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-macos-arm64.zip"}
macFFprobeURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffprobe-macos-arm64.zip"}
} else {
macFFmpegURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-macos-intel.zip"}
macFFprobeURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffprobe-macos-intel.zip"}
}
if !ffmpegInstalled && !ffprobeInstalled {
if err := downloadWithFallback(macFFmpegURLs, ffmpegDir, progressCallback, 0, 50); err != nil {
return err return err
} }
if err := downloadWithFallback(macFFprobeURLs, ffmpegDir, progressCallback, 50, 100); err != nil {
ffprobeURL, _ := decodeBase64(ffprobeMacOSURL) return err
fmt.Printf("[FFmpeg] Downloading ffprobe from: %s\n", ffprobeURL)
if err := downloadAndExtract(ffprobeURL, ffmpegDir, progressCallback, 50, 100); err != nil {
return fmt.Errorf("failed to download ffprobe: %w", err)
} }
} else if !ffmpegInstalled { } else if !ffmpegInstalled {
if err := downloadWithFallback(macFFmpegURLs, ffmpegDir, progressCallback, 0, 100); err != nil {
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 100); err != nil {
return err return err
} }
} else if !ffprobeInstalled { } else if !ffprobeInstalled {
if err := downloadWithFallback(macFFprobeURLs, ffmpegDir, progressCallback, 0, 100); err != nil {
ffprobeURL, _ := decodeBase64(ffprobeMacOSURL) return err
fmt.Printf("[FFmpeg] Downloading ffprobe from: %s\n", ffprobeURL)
if err := downloadAndExtract(ffprobeURL, ffmpegDir, progressCallback, 0, 100); err != nil {
return fmt.Errorf("failed to download ffprobe: %w", err)
} }
} }
return nil return nil
} }
var encodedURL string var url string
switch runtime.GOOS { switch runtime.GOOS {
case "windows": case "windows":
encodedURL = ffmpegWindowsURL url = ffmpegWindowsURL
case "linux": case "linux":
encodedURL = ffmpegLinuxURL url = ffmpegLinuxURL
default: default:
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS) return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
} }
url, err := decodeBase64(encodedURL)
if err != nil {
return fmt.Errorf("failed to decode ffmpeg URL: %w", err)
}
fmt.Printf("[FFmpeg] Downloading from: %s\n", url) fmt.Printf("[FFmpeg] Downloading from: %s\n", url)
if err := downloadAndExtract(url, ffmpegDir, progressCallback, 0, 100); err != nil { if err := downloadAndExtract(url, ffmpegDir, progressCallback, 0, 100); err != nil {
return err return err
} }
@@ -236,6 +223,20 @@ func DownloadFFmpeg(progressCallback func(int)) error {
return nil return nil
} }
func downloadWithFallback(urls []string, destDir string, progressCallback func(int), start, end int) error {
var lastErr error
for _, url := range urls {
fmt.Printf("[FFmpeg] Trying to download from: %s\n", url)
err := downloadAndExtract(url, destDir, progressCallback, start, end)
if err == nil {
return nil
}
lastErr = err
fmt.Printf("[FFmpeg] Attempt failed: %v\n", err)
}
return fmt.Errorf("all download attempts failed: %w", lastErr)
}
func downloadAndExtract(url, destDir string, progressCallback func(int), progressStart, progressEnd int) error { func downloadAndExtract(url, destDir string, progressCallback func(int), progressStart, progressEnd int) error {
tmpFile, err := os.CreateTemp("", "ffmpeg-*") tmpFile, err := os.CreateTemp("", "ffmpeg-*")
@@ -245,7 +246,14 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres
defer os.Remove(tmpFile.Name()) defer os.Remove(tmpFile.Name())
defer tmpFile.Close() defer tmpFile.Close()
resp, err := http.Get(url) client := &http.Client{}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
resp, err := client.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("failed to download: %w", err) return fmt.Errorf("failed to download: %w", err)
} }
+75 -7
View File
@@ -1,7 +1,6 @@
package backend package backend
import ( import (
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -38,6 +37,17 @@ type LyricsResponse struct {
Lines []LyricsLine `json:"lines"` Lines []LyricsLine `json:"lines"`
} }
type SpotifyLyricsLine struct {
TimeTag string `json:"timeTag"`
Words string `json:"words"`
}
type SpotifyLyricsAPIResponse struct {
Error bool `json:"error"`
SyncType string `json:"syncType"`
Lines []SpotifyLyricsLine `json:"lines"`
}
type LyricsDownloadRequest struct { type LyricsDownloadRequest struct {
SpotifyID string `json:"spotify_id"` SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"` TrackName string `json:"track_name"`
@@ -73,9 +83,7 @@ func NewLyricsClient() *LyricsClient {
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string, duration int) (*LyricsResponse, error) { func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string, duration int) (*LyricsResponse, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9nZXQ/YXJ0aXN0X25hbWU9") apiURL := fmt.Sprintf("https://lrclib.net/api/get?artist_name=%s&track_name=%s",
apiURL := fmt.Sprintf("%s%s&track_name=%s",
string(apiBase),
url.QueryEscape(artistName), url.QueryEscape(artistName),
url.QueryEscape(trackName)) url.QueryEscape(trackName))
@@ -167,8 +175,7 @@ func lrcTimestampToMs(timestamp string) int64 {
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) (*LyricsResponse, error) { func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) (*LyricsResponse, error) {
query := fmt.Sprintf("%s %s", artistName, trackName) query := fmt.Sprintf("%s %s", artistName, trackName)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9zZWFyY2g/cT0=") apiURL := fmt.Sprintf("https://lrclib.net/api/search?q=%s", url.QueryEscape(query))
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(query))
resp, err := c.httpClient.Get(apiURL) resp, err := c.httpClient.Get(apiURL)
if err != nil { if err != nil {
@@ -212,6 +219,61 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string)
return c.convertLRCLibToLyricsResponse(best), nil return c.convertLRCLibToLyricsResponse(best), nil
} }
func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) {
if spotifyID == "" {
return nil, fmt.Errorf("spotify ID is empty")
}
apiURL := fmt.Sprintf("https://spotify-lyrics-api-pi.vercel.app/?trackid=%s&format=lrc", spotifyID)
resp, err := c.httpClient.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch from Spotify Lyrics API: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read Spotify Lyrics API response: %v", err)
}
var apiResp SpotifyLyricsAPIResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %v", err)
}
if apiResp.Error {
return nil, fmt.Errorf("Spotify Lyrics API returned error")
}
result := &LyricsResponse{
Error: false,
SyncType: apiResp.SyncType,
Lines: []LyricsLine{},
}
for _, line := range apiResp.Lines {
if line.TimeTag == "" && line.Words == "" {
continue
}
ms := lrcTimestampToMs(line.TimeTag)
result.Lines = append(result.Lines, LyricsLine{
StartTimeMs: fmt.Sprintf("%d", ms),
Words: line.Words,
})
}
if len(result.Lines) == 0 {
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
}
return result, nil
}
func simplifyTrackName(name string) string { func simplifyTrackName(name string) string {
if idx := strings.Index(name, "("); idx > 0 { if idx := strings.Index(name, "("); idx > 0 {
@@ -226,7 +288,13 @@ func simplifyTrackName(name string) string {
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, duration int) (*LyricsResponse, string, error) { func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, duration int) (*LyricsResponse, string, error) {
resp, err := c.FetchLyricsWithMetadata(trackName, artistName, duration) resp, err := c.FetchLyricsFromSpotifyAPI(spotifyID)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "Spotify", nil
}
fmt.Printf(" Spotify Lyrics API: %v\n", err)
resp, err = c.FetchLyricsWithMetadata(trackName, artistName, duration)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 { if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB", nil return resp, "LRCLIB", nil
} }
+5
View File
@@ -32,6 +32,7 @@ type Metadata struct {
Lyrics string Lyrics string
Description string Description string
ISRC string ISRC string
Genre string
} }
func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error { 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) _ = cmt.Add("ISRC", metadata.ISRC)
} }
if metadata.Genre != "" {
_ = cmt.Add("GENRE", metadata.Genre)
}
if metadata.Lyrics != "" { if metadata.Lyrics != "" {
_ = cmt.Add("LYRICS", metadata.Lyrics) _ = cmt.Add("LYRICS", metadata.Lyrics)
} }
+150
View File
@@ -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
}
+25 -83
View File
@@ -118,79 +118,6 @@ func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
return &searchResp.Tracks.Items[0], nil 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&region=%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) { func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, quality string) (string, error) {
apiURL := fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality) apiURL := fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
resp, err := q.client.Get(apiURL) 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.Seed(time.Now().UnixNano())
rand.Shuffle(len(providers), func(i, j int) { providers[i], providers[j] = providers[j], providers[i] }) 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" 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 var deezerISRC string
if spotifyID != "" { if spotifyID != "" {
songlinkClient := NewSongLinkClient() 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 "", 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) 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 outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil { if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("failed to create output directory: %w", err) return "", fmt.Errorf("failed to create output directory: %w", err)
@@ -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...") fmt.Println("Embedding metadata and cover art...")
trackNumberToEmbed := spotifyTrackNumber trackNumberToEmbed := spotifyTrackNumber
@@ -554,6 +495,7 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir
Publisher: spotifyPublisher, Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC", Description: "https://github.com/afkarxyz/SpotiFLAC",
ISRC: deezerISRC, ISRC: deezerISRC,
Genre: mbMeta.Genre,
} }
if err := EmbedMetadata(filepath, metadata, coverPath); err != nil { if err := EmbedMetadata(filepath, metadata, coverPath); err != nil {
+7 -15
View File
@@ -1,7 +1,6 @@
package backend package backend
import ( import (
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -71,11 +70,9 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string, region str
} }
} }
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(spotifyURL))
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
if region != "" { if region != "" {
apiURL += fmt.Sprintf("&userCountry=%s", 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("https://open.spotify.com/track/%s", spotifyTrackID)
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(spotifyURL))
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
req, err := http.NewRequest("GET", apiURL, nil) req, err := http.NewRequest("GET", apiURL, nil)
if err != nil { if err != nil {
@@ -299,8 +294,7 @@ func checkQobuzAvailability(isrc string) bool {
client := &http.Client{Timeout: 10 * time.Second} client := &http.Client{Timeout: 10 * time.Second}
appID := "798273057" appID := "798273057"
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/track/search?query=%s&limit=1&app_id=%s", isrc, appID)
searchURL := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, appID)
resp, err := client.Get(searchURL) resp, err := client.Get(searchURL)
if err != nil { if err != nil {
@@ -352,11 +346,9 @@ func (s *SongLinkClient) GetDeezerURLFromSpotify(spotifyTrackID string) (string,
} }
} }
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(spotifyURL))
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
req, err := http.NewRequest("GET", apiURL, nil) req, err := http.NewRequest("GET", apiURL, nil)
if err != nil { if err != nil {
+6 -42
View File
@@ -2,9 +2,7 @@ package backend
import ( import (
"bytes" "bytes"
"encoding/base32"
"encoding/base64" "encoding/base64"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -41,39 +39,10 @@ func NewSpotifyClient() *SpotifyClient {
} }
} }
func (c *SpotifyClient) getTOTPSecret() (int, []byte) {
secrets := map[int][]byte{
59: {123, 105, 79, 70, 110, 59, 52, 125, 60, 49, 80, 70, 89, 75, 80, 86, 63, 53, 123, 37, 117, 49, 52, 93, 77, 62, 47, 86, 48, 104, 68, 72},
60: {79, 109, 69, 123, 90, 65, 46, 74, 94, 34, 58, 48, 70, 71, 92, 85, 122, 63, 91, 64, 87, 87},
61: {44, 55, 47, 42, 70, 40, 34, 114, 76, 74, 50, 111, 120, 97, 75, 76, 94, 102, 43, 69, 49, 120, 118, 80, 64, 78},
}
version := 61
secretList := secrets[version]
return version, secretList
}
func (c *SpotifyClient) generateTOTP() (string, int, error) { func (c *SpotifyClient) generateTOTP() (string, int, error) {
version, secretList := c.getTOTPSecret()
transformed := make([]byte, len(secretList)) secret := "GM3TMMJTGYZTQNZVGM4DINJZHA4TGOBYGMZTCMRTGEYDSMJRHE4TEOBUG4YTCMRUGQ4DQOJUGQYTAMRRGA2TCMJSHE3TCMBY"
for i, b := range secretList { version := 61
transformed[i] = b ^ byte((i%33)+9)
}
var joined strings.Builder
for _, b := range transformed {
joined.WriteString(strconv.Itoa(int(b)))
}
hexStr := hex.EncodeToString([]byte(joined.String()))
hexBytes, err := hex.DecodeString(hexStr)
if err != nil {
return "", 0, err
}
secret := base32Encode(hexBytes)
secret = strings.TrimRight(secret, "=")
key, err := otp.NewKeyFromURL(fmt.Sprintf("otpauth://totp/secret?secret=%s", secret)) key, err := otp.NewKeyFromURL(fmt.Sprintf("otpauth://totp/secret?secret=%s", secret))
if err != nil { if err != nil {
@@ -88,11 +57,6 @@ func (c *SpotifyClient) generateTOTP() (string, int, error) {
return totpCode, version, nil return totpCode, version, nil
} }
func base32Encode(data []byte) string {
b32 := base32.StdEncoding.WithPadding(base32.NoPadding)
return b32.EncodeToString(data)
}
func (c *SpotifyClient) getAccessToken() error { func (c *SpotifyClient) getAccessToken() error {
totpCode, version, err := c.generateTOTP() totpCode, version, err := c.generateTOTP()
if err != nil { if err != nil {
@@ -112,7 +76,7 @@ func (c *SpotifyClient) getAccessToken() error {
q.Add("totpServer", totpCode) q.Add("totpServer", totpCode)
req.URL.RawQuery = q.Encode() req.URL.RawQuery = q.Encode()
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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") req.Header.Set("Content-Type", "application/json;charset=UTF-8")
resp, err := c.client.Do(req) resp, err := c.client.Do(req)
@@ -149,7 +113,7 @@ func (c *SpotifyClient) getSessionInfo() error {
return err return err
} }
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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 { for name, value := range c.cookies {
req.AddCookie(&http.Cookie{Name: name, Value: value}) req.AddCookie(&http.Cookie{Name: name, Value: value})
@@ -230,7 +194,7 @@ func (c *SpotifyClient) getClientToken() error {
req.Header.Set("Authority", "clienttoken.spotify.com") req.Header.Set("Authority", "clienttoken.spotify.com")
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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) resp, err := c.client.Do(req)
if err != nil { if err != nil {
@@ -288,7 +252,7 @@ func (c *SpotifyClient) Query(payload map[string]interface{}) (map[string]interf
req.Header.Set("Client-Token", c.clientToken) req.Header.Set("Client-Token", c.clientToken)
req.Header.Set("Spotify-App-Version", c.clientVersion) req.Header.Set("Spotify-App-Version", c.clientVersion)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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) resp, err := c.client.Do(req)
if err != nil { if err != nil {
+58 -16
View File
@@ -79,6 +79,8 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) { func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
apis := []string{ apis := []string{
"https://api.monochrome.tf",
"https://arran.monochrome.tf",
"https://triton.squid.wtf", "https://triton.squid.wtf",
"https://hifi-one.spotisaver.net", "https://hifi-one.spotisaver.net",
"https://hifi-two.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) 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...") 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) 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) resp, err := t.client.Do(req)
if err != nil { if err != nil {
@@ -229,7 +231,7 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error {
return fmt.Errorf("failed to create request: %w", err) return fmt.Errorf("failed to create request: %w", err)
} }
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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) resp, err := t.client.Do(req)
@@ -275,7 +277,7 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
if err != nil { if err != nil {
return nil, err 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) return client.Do(req)
} }
@@ -446,7 +448,7 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
return nil return nil
} }
func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, 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 outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil { if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("directory error: %w", err) return "", fmt.Errorf("directory error: %w", err)
@@ -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 != "" { if spotifyURL != "" {
go func() { go func() {
res := mbResult{}
var isrc string var isrc string
parts := strings.Split(spotifyURL, "/") parts := strings.Split(spotifyURL, "/")
if len(parts) > 0 { 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 { } else {
close(isrcChan) close(metaChan)
} }
fmt.Printf("Downloading to: %s\n", outputFilename) fmt.Printf("Downloading to: %s\n", outputFilename)
@@ -526,8 +544,11 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
} }
var isrc string var isrc string
var mbMeta Metadata
if spotifyURL != "" { if spotifyURL != "" {
isrc = <-isrcChan result := <-metaChan
isrc = result.ISRC
mbMeta = result.Metadata
} }
fmt.Println("Adding metadata...") fmt.Println("Adding metadata...")
@@ -566,6 +587,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
Publisher: spotifyPublisher, Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC", Description: "https://github.com/afkarxyz/SpotiFLAC",
ISRC: isrc, ISRC: isrc,
Genre: mbMeta.Genre,
} }
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil { if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
@@ -579,7 +601,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
return outputFilename, nil return outputFilename, nil
} }
func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, 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() apis, err := t.GetAvailableAPIs()
if err != nil { if err != nil {
return "", fmt.Errorf("no APIs available for fallback: %w", err) return "", fmt.Errorf("no APIs available for fallback: %w", err)
@@ -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 != "" { if spotifyURL != "" {
go func() { go func() {
res := mbResultFallback{}
var isrc string var isrc string
parts := strings.Split(spotifyURL, "/") parts := strings.Split(spotifyURL, "/")
if len(parts) > 0 { 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 { } else {
close(isrcChan) close(metaChan)
} }
fmt.Printf("Downloading to: %s\n", outputFilename) fmt.Printf("Downloading to: %s\n", outputFilename)
@@ -665,8 +703,11 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
} }
var isrc string var isrc string
var mbMeta Metadata
if spotifyURL != "" { if spotifyURL != "" {
isrc = <-isrcChan result := <-metaChan
isrc = result.ISRC
mbMeta = result.Metadata
} }
fmt.Println("Adding metadata...") fmt.Println("Adding metadata...")
@@ -705,6 +746,7 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
Publisher: spotifyPublisher, Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC", Description: "https://github.com/afkarxyz/SpotiFLAC",
ISRC: isrc, ISRC: isrc,
Genre: mbMeta.Genre,
} }
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil { if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
@@ -718,14 +760,14 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
return outputFilename, nil return outputFilename, nil
} }
func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, 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) tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
if err != nil { if err != nil {
return "", fmt.Errorf("songlink couldn't find Tidal URL: %w", err) return "", fmt.Errorf("songlink couldn't find Tidal URL: %w", err)
} }
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, 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 { type SegmentTemplate struct {
+3 -3
View File
@@ -76,7 +76,7 @@ func uploadToService(filename string, fileReader io.Reader) (string, error) {
return "", err return "", err
} }
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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("Origin", "https://send.now")
req.Header.Set("Referer", "https://send.now/") req.Header.Set("Referer", "https://send.now/")
req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Content-Type", writer.FormDataContentType())
@@ -123,7 +123,7 @@ func getUploadURL() (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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} client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req) resp, err := client.Do(req)
@@ -162,7 +162,7 @@ func fetchDirectImageLink(url string) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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} client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req) resp, err := client.Do(req)
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.0 KiB

+334 -235
View File
@@ -2,12 +2,12 @@ import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { openExternal } from "@/lib/utils"; import { openExternal } from "@/lib/utils";
import { GetOSInfo } from "../../wailsjs/go/main/App"; import { GetOSInfo } from "../../wailsjs/go/main/App";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Bug, Lightbulb, ExternalLink, Star, GitFork, Clock, Download, 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 AudioTTSProIcon from "@/assets/audiotts-pro.webp";
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp"; import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
import XProIcon from "@/assets/x-pro.webp"; import XProIcon from "@/assets/x-pro.webp";
@@ -15,7 +15,6 @@ import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg"; import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg";
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg"; import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
import SpotiFLACNextIcon from "@/assets/icons/next.svg"; import SpotiFLACNextIcon from "@/assets/icons/next.svg";
import BmcLogo from "@/assets/bmc-logo.svg";
import KofiLogo from "@/assets/kofi_symbol.svg"; import KofiLogo from "@/assets/kofi_symbol.svg";
import { langColors } from "@/assets/github-lang-colors"; import { langColors } from "@/assets/github-lang-colors";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
@@ -54,14 +53,14 @@ export function AboutPage({ version }: AboutPageProps) {
fetchOS(); fetchOS();
const fetchLocation = async () => { const fetchLocation = async () => {
try { try {
const response = await fetch('https://ipapi.co/json/'); const response = await fetch("https://ipapi.co/json/");
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
const city = data.city || ''; const city = data.city || "";
const region = data.region || ''; const region = data.region || "";
const country = data.country_name || ''; const country = data.country_name || "";
const parts = [city, region, country].filter(Boolean); const parts = [city, region, country].filter(Boolean);
setLocation(parts.join(', ') || 'Unknown'); setLocation(parts.join(", ") || "Unknown");
} }
else { else {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -75,7 +74,7 @@ export function AboutPage({ version }: AboutPageProps) {
}; };
fetchLocation(); fetchLocation();
const fetchRepoStats = async () => { const fetchRepoStats = async () => {
const CACHE_KEY = 'github_repo_stats'; const CACHE_KEY = "github_repo_stats";
const CACHE_DURATION = 1000 * 60 * 60; const CACHE_DURATION = 1000 * 60 * 60;
const cached = localStorage.getItem(CACHE_KEY); const cached = localStorage.getItem(CACHE_KEY);
if (cached) { if (cached) {
@@ -87,13 +86,13 @@ export function AboutPage({ version }: AboutPageProps) {
} }
} }
catch (err) { catch (err) {
console.error('Failed to parse cache:', err); console.error("Failed to parse cache:", err);
} }
} }
const repos = [ const repos = [
{ name: 'SpotiDownloader', owner: 'afkarxyz' }, { name: "SpotiDownloader", owner: "afkarxyz" },
{ name: 'SpotiFLAC-Next', owner: 'spotiverse' }, { name: "SpotiFLAC-Next", owner: "spotiverse" },
{ name: 'Twitter-X-Media-Batch-Downloader', owner: 'afkarxyz' } { name: "Twitter-X-Media-Batch-Downloader", owner: "afkarxyz" },
]; ];
const stats: Record<string, any> = {}; const stats: Record<string, any> = {};
for (const repo of repos) { for (const repo of repos) {
@@ -101,7 +100,7 @@ export function AboutPage({ version }: AboutPageProps) {
const [repoRes, releasesRes, langsRes] = await Promise.all([ const [repoRes, releasesRes, langsRes] = await Promise.all([
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}`), fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}`),
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/releases`), fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/releases`),
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/languages`) fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/languages`),
]); ]);
if (repoRes.status === 403) { if (repoRes.status === 403) {
if (cached) { if (cached) {
@@ -117,9 +116,11 @@ export function AboutPage({ version }: AboutPageProps) {
let totalDownloads = 0; let totalDownloads = 0;
let latestDownloads = 0; let latestDownloads = 0;
if (releases.length > 0) { if (releases.length > 0) {
latestDownloads = releases[0].assets?.reduce((sum: number, asset: any) => sum + (asset.download_count || 0), 0) || 0; latestDownloads =
releases[0].assets?.reduce((sum: number, asset: any) => sum + (asset.download_count || 0), 0) || 0;
totalDownloads = releases.reduce((sum: number, release: any) => { totalDownloads = releases.reduce((sum: number, release: any) => {
return sum + (release.assets?.reduce((s: number, a: any) => s + (a.download_count || 0), 0) || 0); return (sum +
(release.assets?.reduce((s: number, a: any) => s + (a.download_count || 0), 0) || 0));
}, 0); }, 0);
} }
const topLangs = Object.entries(languages) const topLangs = Object.entries(languages)
@@ -132,7 +133,7 @@ export function AboutPage({ version }: AboutPageProps) {
createdAt: repoData.created_at, createdAt: repoData.created_at,
totalDownloads, totalDownloads,
latestDownloads, latestDownloads,
languages: topLangs languages: topLangs,
}; };
} }
} }
@@ -153,24 +154,24 @@ export function AboutPage({ version }: AboutPageProps) {
const faqs = [ const faqs = [
{ {
q: "Is this software free?", q: "Is this software free?",
a: "Yes. This software is completely free. You do not need an account, login, or subscription. All you need is an internet connection." a: "Yes. This software is completely free. You do not need an account, login, or subscription. All you need is an internet connection.",
}, },
{ {
q: "Can using this software get my Spotify account suspended or banned?", q: "Can using this software get my Spotify account suspended or banned?",
a: "No. This software has no connection to your Spotify account. Spotify data is obtained through reverse engineering of the Spotify Web Player, not through user authentication." a: "No. This software has no connection to your Spotify account. Spotify data is obtained through reverse engineering of the Spotify Web Player, not through user authentication.",
}, },
{ {
q: "Where does the audio come from?", q: "Where does the audio come from?",
a: "The audio is fetched using third-party APIs." a: "The audio is fetched using third-party APIs.",
}, },
{ {
q: "Why does metadata fetching sometimes fail?", q: "Why does metadata fetching sometimes fail?",
a: "This usually happens because your IP address has been rate-limited. You can wait and try again later, or use a VPN to bypass the rate limit." a: "This usually happens because your IP address has been rate-limited. You can wait and try again later, or use a VPN to bypass the rate limit.",
}, },
{ {
q: "Why does Windows Defender or antivirus flag or delete the file?", q: "Why does Windows Defender or antivirus flag or delete the file?",
a: "This is a false positive. It likely happens because the executable is compressed using UPX. If you are concerned, you can fork the repository and build the software yourself from source." a: "This is a false positive. It likely happens because the executable is compressed using UPX. If you are concerned, you can fork the repository and build the software yourself from source.",
} },
]; ];
const formatTimeAgo = (dateString: string): string => { const formatTimeAgo = (dateString: string): string => {
const now = new Date(); const now = new Date();
@@ -179,13 +180,13 @@ export function AboutPage({ version }: AboutPageProps) {
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const diffMonths = Math.floor(diffDays / 30); const diffMonths = Math.floor(diffDays / 30);
if (diffDays === 0) if (diffDays === 0)
return 'today'; return "today";
if (diffDays === 1) if (diffDays === 1)
return '1d'; return "1d";
if (diffDays < 30) if (diffDays < 30)
return `${diffDays}d`; return `${diffDays}d`;
if (diffMonths === 1) if (diffMonths === 1)
return '1mo'; return "1mo";
if (diffMonths < 12) if (diffMonths < 12)
return `${diffMonths}mo`; return `${diffMonths}mo`;
const diffYears = Math.floor(diffMonths / 12); const diffYears = Math.floor(diffMonths / 12);
@@ -198,7 +199,7 @@ export function AboutPage({ version }: AboutPageProps) {
return num.toString(); return num.toString();
}; };
const getLangColor = (lang: string): string => { const getLangColor = (lang: string): string => {
return langColors[lang] || '#858585'; return langColors[lang] || "#858585";
}; };
const handleSubmit = () => { const handleSubmit = () => {
const title = activeTab === "bug_report" const title = activeTab === "bug_report"
@@ -206,7 +207,9 @@ export function AboutPage({ version }: AboutPageProps) {
: `[Feature Request] ${featureDesc.substring(0, 50)}${featureDesc.length > 50 ? "..." : ""}`; : `[Feature Request] ${featureDesc.substring(0, 50)}${featureDesc.length > 50 ? "..." : ""}`;
let bodyContent = ""; let bodyContent = "";
if (activeTab === "bug_report") { 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] bodyContent = `### [Bug Report]
#### Problem #### Problem
@@ -227,7 +230,9 @@ ${contextContent}
- Location: ${location}`; - Location: ${location}`;
} }
else { 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] bodyContent = `### [Feature Request]
#### Description #### Description
@@ -241,226 +246,320 @@ ${contextContent}`;
} }
const params = new URLSearchParams({ const params = new URLSearchParams({
title: title, title: title,
body: bodyContent body: bodyContent,
}); });
const url = `https://github.com/afkarxyz/SpotiFLAC/issues/new?${params.toString()}`; const url = `https://github.com/afkarxyz/SpotiFLAC/issues/new?${params.toString()}`;
openExternal(url); openExternal(url);
}; };
return (<div className={`flex flex-col space-y-4 ${activeTab === "faq" ? "h-[calc(100vh-10rem)]" : ""}`}> return (<div className={`flex flex-col space-y-4 ${activeTab === "faq" ? "h-[calc(100vh-10rem)]" : ""}`}>
<div className="flex items-center justify-between shrink-0"> <div className="flex items-center justify-between shrink-0">
<h2 className="text-2xl font-bold tracking-tight">About</h2> <h2 className="text-2xl font-bold tracking-tight">About</h2>
</div> </div>
<div className="flex gap-2 border-b shrink-0"> <div className="flex gap-2 border-b shrink-0">
<Button variant={activeTab === "bug_report" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("bug_report")} className="rounded-b-none"> <Button variant={activeTab === "bug_report" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("bug_report")} className="rounded-b-none">
<Bug className="h-4 w-4"/> <Bug className="h-4 w-4"/>
Bug Report Bug Report
</Button> </Button>
<Button variant={activeTab === "feature_request" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("feature_request")} className="rounded-b-none"> <Button variant={activeTab === "feature_request" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("feature_request")} className="rounded-b-none">
<Lightbulb className="h-4 w-4"/> <Lightbulb className="h-4 w-4"/>
Feature Request Feature Request
</Button> </Button>
<Button variant={activeTab === "faq" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("faq")} className="rounded-b-none"> <Button variant={activeTab === "faq" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("faq")} className="rounded-b-none">
<CircleHelp className="h-4 w-4"/> <CircleHelp className="h-4 w-4"/>
FAQ FAQ
</Button> </Button>
<Button variant={activeTab === "projects" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("projects")} className="rounded-b-none"> <Button variant={activeTab === "projects" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("projects")} className="rounded-b-none">
<Blocks className="h-4 w-4"/> <Blocks className="h-4 w-4"/>
Other Projects Other Projects
</Button> </Button>
<Button variant={activeTab === "support" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("support")} className="rounded-b-none"> <Button variant={activeTab === "support" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("support")} className="rounded-b-none">
<Heart className="h-4 w-4"/> <Heart className="h-4 w-4"/>
Support Us Support Me
</Button> </Button>
</div> </div>
<div className={`flex-1 min-h-0 ${activeTab === "faq" ? "overflow-hidden" : ""}`}> <div className={`flex-1 min-h-0 ${activeTab === "faq" ? "overflow-hidden" : ""}`}>
{activeTab === "bug_report" && (<div className="flex flex-col"> {activeTab === "bug_report" && (<div className="flex flex-col">
<div className="space-y-4 pt-4 flex flex-col"> <div className="space-y-4 pt-4 flex flex-col">
<div className="mt-4 pr-2"> <div className="mt-4 pr-2">
<div className="grid md:grid-cols-3 gap-6"> <div className="grid md:grid-cols-3 gap-6">
<div className="space-y-2 flex flex-col"> <div className="space-y-2 flex flex-col">
<Label>Problem</Label> <Label>Problem</Label>
<Textarea className="h-56 resize-none" placeholder="Describe the problem..." value={problem} onChange={e => setProblem(e.target.value)}/> <Textarea className="h-56 resize-none" placeholder="Describe the problem..." value={problem} onChange={(e) => setProblem(e.target.value)}/>
</div> </div>
<div className="space-y-2 flex flex-col"> <div className="space-y-2 flex flex-col">
<Label>Additional Context</Label> <Label>Additional Context</Label>
<DragDropMedia className="min-h-[14rem]" value={bugContext} onChange={setBugContext}/> <DragDropMedia className="min-h-[14rem]" value={bugContext} onChange={setBugContext}/>
</div> </div>
<div className="space-y-4 flex flex-col"> <div className="space-y-4 flex flex-col">
<div className="space-y-2"> <div className="space-y-2">
<Label>Type</Label> <Label>Type</Label>
<ToggleGroup type="single" value={bugType} onValueChange={(val) => { <ToggleGroup type="single" value={bugType} onValueChange={(val) => {
if (val) if (val)
setBugType(val); setBugType(val);
}} className="justify-start w-full cursor-pointer"> }} className="justify-start w-full cursor-pointer">
<ToggleGroupItem value="Track" className="flex-1 cursor-pointer" aria-label="Toggle track">Track</ToggleGroupItem> <ToggleGroupItem value="Track" className="flex-1 cursor-pointer" aria-label="Toggle track">
<ToggleGroupItem value="Album" className="flex-1 cursor-pointer" aria-label="Toggle album">Album</ToggleGroupItem> Track
<ToggleGroupItem value="Playlist" className="flex-1 cursor-pointer" aria-label="Toggle playlist">Playlist</ToggleGroupItem> </ToggleGroupItem>
<ToggleGroupItem value="Artist" className="flex-1 cursor-pointer" aria-label="Toggle artist">Artist</ToggleGroupItem> <ToggleGroupItem value="Album" className="flex-1 cursor-pointer" aria-label="Toggle album">
</ToggleGroup> Album
</div> </ToggleGroupItem>
<div className="space-y-2"> <ToggleGroupItem value="Playlist" className="flex-1 cursor-pointer" aria-label="Toggle playlist">
<Label>Spotify URL</Label> Playlist
<Input placeholder="https://open.spotify.com/..." value={spotifyUrl} onChange={e => setSpotifyUrl(e.target.value)}/> </ToggleGroupItem>
</div> <ToggleGroupItem value="Artist" className="flex-1 cursor-pointer" aria-label="Toggle artist">
</div> Artist
</div> </ToggleGroupItem>
</div> </ToggleGroup>
</div> </div>
<div className="flex justify-center pt-4 shrink-0"> <div className="space-y-2">
<Button className="w-[200px] cursor-pointer gap-2" onClick={handleSubmit}> <Label>Spotify URL</Label>
<ExternalLink className="h-4 w-4"/> Create Issue on GitHub <Input placeholder="https://open.spotify.com/..." value={spotifyUrl} onChange={(e) => setSpotifyUrl(e.target.value)}/>
</Button>
</div> </div>
</div>)} </div>
{activeTab === "feature_request" && (<div className="flex flex-col">
<div className="space-y-4 pt-4 flex flex-col">
<div className="mt-4 pr-2">
<div className="grid md:grid-cols-3 gap-6">
<div className="space-y-2 flex flex-col">
<Label>Description</Label>
<Textarea className="h-56 resize-none" placeholder="Describe your feature request..." value={featureDesc} onChange={e => setFeatureDesc(e.target.value)}/>
</div>
<div className="space-y-2 flex-col">
<Label>Use Case</Label>
<Textarea className="h-56 resize-none" placeholder="How would this feature be useful?" value={useCase} onChange={e => setUseCase(e.target.value)}/>
</div>
<div className="space-y-2 flex-col">
<Label>Additional Context</Label>
<DragDropMedia className="min-h-[14rem]" value={featureContext} onChange={setFeatureContext}/>
</div>
</div>
</div>
</div>
<div className="flex justify-center pt-4 shrink-0">
<Button className="w-[200px] cursor-pointer gap-2" onClick={handleSubmit}>
<ExternalLink className="h-4 w-4"/> Create Issue on GitHub
</Button>
</div>
</div>)}
{activeTab === "faq" && (<ScrollArea className="h-full">
<div className="p-1 pr-4">
<Card>
<CardHeader>
<CardTitle>Frequently Asked Questions</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{faqs.map((faq, index) => (<div key={index} className="space-y-2">
<h3 className="font-medium text-base text-foreground/90">{faq.q}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">{faq.a}</p>
</div>))}
</CardContent>
</Card>
</div>
</ScrollArea>)}
{activeTab === "projects" && (<div className="p-1 pr-2">
<div className="grid gap-2 grid-cols-4">
<div className="flex flex-col gap-2 h-full">
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer flex-1" onClick={() => openExternal("https://exyezed.cc/")}>
<CardHeader>
<CardTitle>Browser Extensions & Scripts</CardTitle>
<CardDescription className="flex gap-3 pt-2">
<img src={AudioTTSProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="AudioTTS Pro"/>
<img src={ChatGPTTTSIcon} className="h-8 w-8 rounded-md shadow-sm" alt="ChatGPT TTS"/>
<img src={XProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="X Pro"/>
</CardDescription>
</CardHeader>
</Card>
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer flex-1" onClick={() => openExternal("https://spotubedl.com/")}>
<CardHeader>
<CardTitle className="flex items-center gap-2"><img src={SpotubeDLIcon} className="h-5 w-5" alt="SpotubeDL"/> SpotubeDL</CardTitle>
<CardDescription>Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus with High Quality.</CardDescription>
</CardHeader>
</Card>
</div>
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/SpotiDownloader")}>
<CardHeader>
<CardTitle className="flex items-center gap-2"><img src={SpotiDownloaderIcon} className="h-5 w-5" alt="SpotiDownloader"/> SpotiDownloader</CardTitle>
<CardDescription>Get Spotify tracks in MP3 and FLAC via spotidownloader.com</CardDescription>
</CardHeader>
{repoStats['SpotiDownloader'] && (<CardContent className="space-y-3">
<div className="flex flex-wrap gap-2 text-xs">
{repoStats['SpotiDownloader'].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{ backgroundColor: getLangColor(lang) + '20', color: getLangColor(lang) }}>{lang}</span>))}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/> {formatNumber(repoStats['SpotiDownloader'].stars)}</span>
<span className="flex items-center gap-1"><GitFork className="h-3.5 w-3.5"/> {repoStats['SpotiDownloader'].forks}</span>
<span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5"/> {formatTimeAgo(repoStats['SpotiDownloader'].createdAt)}</span>
</div>
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
<span className="flex items-center gap-1"><Download className="h-3.5 w-3.5"/> TOTAL: {formatNumber(repoStats['SpotiDownloader'].totalDownloads)}</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400"><Download className="h-3.5 w-3.5"/> LATEST: {formatNumber(repoStats['SpotiDownloader'].latestDownloads)}</span>
</div>
</CardContent>)}
</Card>
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/spotiverse/SpotiFLAC-Next")}>
<CardHeader>
<CardTitle className="flex items-center gap-2"><img src={SpotiFLACNextIcon} className="h-5 w-5" alt="SpotiFLAC Next"/> SpotiFLAC Next</CardTitle>
<CardDescription>Get Spotify tracks in Hi-Res lossless FLACs no account required.</CardDescription>
</CardHeader>
{repoStats['SpotiFLAC-Next'] && (<CardContent className="space-y-3">
<div className="flex flex-wrap gap-2 text-xs">
{repoStats['SpotiFLAC-Next'].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{ backgroundColor: getLangColor(lang) + '20', color: getLangColor(lang) }}>{lang}</span>))}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/> {formatNumber(repoStats['SpotiFLAC-Next'].stars)}</span>
<span className="flex items-center gap-1"><GitFork className="h-3.5 w-3.5"/> {repoStats['SpotiFLAC-Next'].forks}</span>
<span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5"/> {formatTimeAgo(repoStats['SpotiFLAC-Next'].createdAt)}</span>
</div>
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
<span className="flex items-center gap-1"><Download className="h-3.5 w-3.5"/> TOTAL: {formatNumber(repoStats['SpotiFLAC-Next'].totalDownloads)}</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400"><Download className="h-3.5 w-3.5"/> LATEST: {formatNumber(repoStats['SpotiFLAC-Next'].latestDownloads)}</span>
</div>
</CardContent>)}
</Card>
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader")}>
<CardHeader>
<CardTitle className="flex items-center gap-2"><img src={XBatchDLIcon} className="h-5 w-5" alt="Twitter/X Media Batch Downloader"/> Twitter/X Media Batch Downloader</CardTitle>
<CardDescription>A GUI tool to download original-quality images and videos from Twitter/X accounts, powered by gallery-dl by @mikf</CardDescription>
</CardHeader>
{repoStats['Twitter-X-Media-Batch-Downloader'] && (<CardContent className="space-y-3">
<div className="flex flex-wrap gap-2 text-xs">
{repoStats['Twitter-X-Media-Batch-Downloader'].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{ backgroundColor: getLangColor(lang) + '20', color: getLangColor(lang) }}>{lang}</span>))}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/> {formatNumber(repoStats['Twitter-X-Media-Batch-Downloader'].stars)}</span>
<span className="flex items-center gap-1"><GitFork className="h-3.5 w-3.5"/> {repoStats['Twitter-X-Media-Batch-Downloader'].forks}</span>
<span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5"/> {formatTimeAgo(repoStats['Twitter-X-Media-Batch-Downloader'].createdAt)}</span>
</div>
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
<span className="flex items-center gap-1"><Download className="h-3.5 w-3.5"/> TOTAL: {formatNumber(repoStats['Twitter-X-Media-Batch-Downloader'].totalDownloads)}</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400"><Download className="h-3.5 w-3.5"/> LATEST: {formatNumber(repoStats['Twitter-X-Media-Batch-Downloader'].latestDownloads)}</span>
</div>
</CardContent>)}
</Card>
</div>
</div>)}
{activeTab === "support" && (<div className="flex flex-col items-center justify-center p-8 space-y-8">
<div className="text-center space-y-2">
<h3 className="text-2xl font-bold tracking-tight">Support Our Work</h3>
<p className="text-muted-foreground max-w-[500px]">
If this software is useful and brings you value, consider supporting the project by buying me a coffee. Your support helps keep development going.
</p>
</div> </div>
</div>
</div>
<div className="flex justify-center pt-4 shrink-0">
<Button className="w-[200px] cursor-pointer gap-2" onClick={handleSubmit}>
<ExternalLink className="h-4 w-4"/> Create Issue on GitHub
</Button>
</div>
</div>)}
<div className="grid sm:grid-cols-2 gap-4 w-full max-w-lg"> {activeTab === "feature_request" && (<div className="flex flex-col">
<Button size="lg" className="h-16 text-lg font-semibold text-white gap-3 group" style={{ backgroundColor: "#72a4f2" }} onClick={() => openExternal("https://ko-fi.com/afkarxyz")}> <div className="space-y-4 pt-4 flex flex-col">
<img src={KofiLogo} className="h-8 w-8 transition-transform group-hover:scale-110" alt="Ko-fi"/> <div className="mt-4 pr-2">
Support me on Ko-fi <div className="grid md:grid-cols-3 gap-6">
</Button> <div className="space-y-2 flex flex-col">
<Label>Description</Label>
<Button size="lg" className="h-16 text-lg font-semibold text-black gap-3 group" style={{ backgroundColor: "#ffdd00" }} onClick={() => openExternal("https://buymeacoffee.com/afkarxyz")}> <Textarea className="h-56 resize-none" placeholder="Describe your feature request..." value={featureDesc} onChange={(e) => setFeatureDesc(e.target.value)}/>
<img src={BmcLogo} className="h-6 w-6 transition-transform group-hover:scale-110" alt="Buy Me a Coffee"/> </div>
Buy Me a Coffee <div className="space-y-2 flex-col">
</Button> <Label>Use Case</Label>
<Textarea className="h-56 resize-none" placeholder="How would this feature be useful?" value={useCase} onChange={(e) => setUseCase(e.target.value)}/>
</div>
<div className="space-y-2 flex-col">
<Label>Additional Context</Label>
<DragDropMedia className="min-h-[14rem]" value={featureContext} onChange={setFeatureContext}/>
</div>
</div> </div>
</div>)} </div>
</div> </div>
<div className="flex justify-center pt-4 shrink-0">
<Button className="w-[200px] cursor-pointer gap-2" onClick={handleSubmit}>
<ExternalLink className="h-4 w-4"/> Create Issue on GitHub
</Button>
</div>
</div>)}
{activeTab === "faq" && (<ScrollArea className="h-full">
<div className="p-1 pr-4">
<Card>
<CardHeader>
<CardTitle>Frequently Asked Questions</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{faqs.map((faq, index) => (<div key={index} className="space-y-2">
<h3 className="font-medium text-base text-foreground/90">
{faq.q}
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
{faq.a}
</p>
</div>))}
</CardContent>
</Card>
</div>
</ScrollArea>)}
{activeTab === "projects" && (<div className="p-1 pr-2">
<div className="grid gap-2 grid-cols-4">
<div className="flex flex-col gap-2 h-full">
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer flex-1" onClick={() => openExternal("https://exyezed.cc/")}>
<CardHeader>
<CardTitle>Browser Extensions & Scripts</CardTitle>
<CardDescription className="flex gap-3 pt-2">
<img src={AudioTTSProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="AudioTTS Pro"/>
<img src={ChatGPTTTSIcon} className="h-8 w-8 rounded-md shadow-sm" alt="ChatGPT TTS"/>
<img src={XProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="X Pro"/>
</CardDescription>
</CardHeader>
</Card>
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer flex-1" onClick={() => openExternal("https://spotubedl.com/")}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<img src={SpotubeDLIcon} className="h-5 w-5" alt="SpotubeDL"/>{" "}
SpotubeDL
</CardTitle>
<CardDescription>
Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus
with High Quality.
</CardDescription>
</CardHeader>
</Card>
</div>
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/SpotiDownloader")}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<img src={SpotiDownloaderIcon} className="h-5 w-5" alt="SpotiDownloader"/>{" "}
SpotiDownloader
</CardTitle>
<CardDescription>
Get Spotify tracks in MP3 and FLAC via spotidownloader.com
</CardDescription>
</CardHeader>
{repoStats["SpotiDownloader"] && (<CardContent className="space-y-3">
<div className="flex flex-wrap gap-2 text-xs">
{repoStats["SpotiDownloader"].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
backgroundColor: getLangColor(lang) + "20",
color: getLangColor(lang),
}}>
{lang}
</span>))}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/>{" "}
{formatNumber(repoStats["SpotiDownloader"].stars)}
</span>
<span className="flex items-center gap-1">
<GitFork className="h-3.5 w-3.5"/>{" "}
{repoStats["SpotiDownloader"].forks}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5"/>{" "}
{formatTimeAgo(repoStats["SpotiDownloader"].createdAt)}
</span>
</div>
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
<span className="flex items-center gap-1">
<Download className="h-3.5 w-3.5"/> TOTAL:{" "}
{formatNumber(repoStats["SpotiDownloader"].totalDownloads)}
</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<Download className="h-3.5 w-3.5"/> LATEST:{" "}
{formatNumber(repoStats["SpotiDownloader"].latestDownloads)}
</span>
</div>
</CardContent>)}
</Card>
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/spotiverse/SpotiFLAC-Next")}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<img src={SpotiFLACNextIcon} className="h-5 w-5" alt="SpotiFLAC Next"/>{" "}
SpotiFLAC Next
</CardTitle>
<CardDescription>
Get Spotify tracks in Hi-Res lossless FLACs no account
required.
</CardDescription>
</CardHeader>
{repoStats["SpotiFLAC-Next"] && (<CardContent className="space-y-3">
<div className="flex flex-wrap gap-2 text-xs">
{repoStats["SpotiFLAC-Next"].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
backgroundColor: getLangColor(lang) + "20",
color: getLangColor(lang),
}}>
{lang}
</span>))}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/>{" "}
{formatNumber(repoStats["SpotiFLAC-Next"].stars)}
</span>
<span className="flex items-center gap-1">
<GitFork className="h-3.5 w-3.5"/>{" "}
{repoStats["SpotiFLAC-Next"].forks}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5"/>{" "}
{formatTimeAgo(repoStats["SpotiFLAC-Next"].createdAt)}
</span>
</div>
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
<span className="flex items-center gap-1">
<Download className="h-3.5 w-3.5"/> TOTAL:{" "}
{formatNumber(repoStats["SpotiFLAC-Next"].totalDownloads)}
</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<Download className="h-3.5 w-3.5"/> LATEST:{" "}
{formatNumber(repoStats["SpotiFLAC-Next"].latestDownloads)}
</span>
</div>
</CardContent>)}
</Card>
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader")}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<img src={XBatchDLIcon} className="h-5 w-5" alt="Twitter/X Media Batch Downloader"/>{" "}
Twitter/X Media Batch Downloader
</CardTitle>
<CardDescription>
A GUI tool to download original-quality images and videos
from Twitter/X accounts, powered by gallery-dl by @mikf
</CardDescription>
</CardHeader>
{repoStats["Twitter-X-Media-Batch-Downloader"] && (<CardContent className="space-y-3">
<div className="flex flex-wrap gap-2 text-xs">
{repoStats["Twitter-X-Media-Batch-Downloader"].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
backgroundColor: getLangColor(lang) + "20",
color: getLangColor(lang),
}}>
{lang}
</span>))}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/>{" "}
{formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"].stars)}
</span>
<span className="flex items-center gap-1">
<GitFork className="h-3.5 w-3.5"/>{" "}
{repoStats["Twitter-X-Media-Batch-Downloader"].forks}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5"/>{" "}
{formatTimeAgo(repoStats["Twitter-X-Media-Batch-Downloader"]
.createdAt)}
</span>
</div>
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
<span className="flex items-center gap-1">
<Download className="h-3.5 w-3.5"/> TOTAL:{" "}
{formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"]
.totalDownloads)}
</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<Download className="h-3.5 w-3.5"/> LATEST:{" "}
{formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"]
.latestDownloads)}
</span>
</div>
</CardContent>)}
</Card>
</div>
</div>)}
{activeTab === "support" && (<div className="flex flex-col items-center justify-center p-8 space-y-8">
<div className="text-center space-y-2">
<h3 className="text-2xl font-bold tracking-tight">Support Me</h3>
<p className="text-muted-foreground max-w-[500px]">
If this software is useful and brings you value, consider
supporting the project on Ko-fi. Your support helps keep
development going.
</p>
</div>
<div className="flex justify-center w-full max-w-lg">
<Button size="lg" className="h-16 text-lg font-semibold text-white gap-3 group" style={{ backgroundColor: "#72a4f2" }} onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
<img src={KofiLogo} className="h-8 w-8 transition-transform group-hover:scale-110" alt="Ko-fi"/>
Support me on Ko-fi
</Button>
</div>
</div>)}
</div>
</div>); </div>);
} }
+11
View File
@@ -451,6 +451,15 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
Embed Max Quality Cover Embed Max Quality Cover
</Label> </Label>
</div> </div>
<div className="flex items-center gap-3">
<Switch id="use-single-genre" checked={tempSettings.useSingleGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
useSingleGenre: checked,
}))}/>
<Label htmlFor="use-single-genre" className="text-sm cursor-pointer font-normal">
Use Single Genre
</Label>
</div>
</div> </div>
</div> </div>
</div>)} </div>)}
@@ -539,6 +548,8 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
Use First Artist Only Use First Artist Only
</Label> </Label>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
+95 -110
View File
@@ -10,9 +10,6 @@ import { BadgeAlertIcon } from "@/components/ui/badge-alert";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { openExternal } from "@/lib/utils"; import { openExternal } from "@/lib/utils";
import BmcLogo from "@/assets/bmc-logo-side.svg";
import BmcLogoWhite from "@/assets/bmc-logo-side-white.svg";
import KofiLogo from "@/assets/kofi_symbol.svg";
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager" | "about" | "history"; export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager" | "about" | "history";
interface SidebarProps { interface SidebarProps {
currentPage: PageType; currentPage: PageType;
@@ -20,118 +17,106 @@ interface SidebarProps {
} }
export function Sidebar({ currentPage, onPageChange }: SidebarProps) { export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
return (<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30"> return (<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
<div className="flex flex-col gap-2 flex-1"> <div className="flex flex-col gap-2 flex-1">
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant={currentPage === "main" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "main" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("main")}>
<HomeIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Home</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant={currentPage === "main" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "main" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("main")}> <Button variant={currentPage === "history" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "history" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("history")}>
<HomeIcon size={20}/> <HistoryIcon size={20}/>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>Home</p> <p>History</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant={currentPage === "history" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "history" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("history")}> <Button variant={currentPage === "audio-analysis" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-analysis" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-analysis")}>
<HistoryIcon size={20}/> <ActivityIcon size={20}/>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>History</p> <p>Audio Quality Analyzer</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant={currentPage === "audio-analysis" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-analysis" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-analysis")}> <Button variant={currentPage === "audio-converter" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-converter" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-converter")}>
<ActivityIcon size={20}/> <FileMusicIcon size={20}/>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>Audio Quality Analyzer</p> <p>Audio Converter</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant={currentPage === "audio-converter" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-converter" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-converter")}> <Button variant={currentPage === "file-manager" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "file-manager" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("file-manager")}>
<FileMusicIcon size={20}/> <FilePenIcon size={20}/>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>Audio Converter</p> <p>File Manager</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant={currentPage === "file-manager" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "file-manager" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("file-manager")}> <Button variant={currentPage === "debug" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "debug" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("debug")}>
<FilePenIcon size={20}/> <TerminalIcon size={20} loop={true}/>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>File Manager</p> <p>Debug Logs</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant={currentPage === "debug" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "debug" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("debug")}> <Button variant={currentPage === "settings" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "settings" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("settings")}>
<TerminalIcon size={20} loop={true}/> <SettingsIcon size={20}/>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>Debug Logs</p> <p>Settings</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant={currentPage === "settings" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "settings" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("settings")}>
<SettingsIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Settings</p>
</TooltipContent>
</Tooltip>
</div>
<div className="mt-auto flex flex-col gap-2">
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant={currentPage === "about" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "about" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("about")}>
<BadgeAlertIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>About</p>
</TooltipContent>
</Tooltip>
<div className="relative group">
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary">
<CoffeeIcon size={20} loop={true}/>
</Button>
<div className="absolute left-10 bottom-0 w-4 h-full bg-transparent"/>
<div className="absolute left-10 bottom-0 mb-0 ml-3 hidden group-hover:flex flex-col gap-1 p-1 bg-popover border border-border rounded-md shadow-md z-50 w-max animate-in fade-in zoom-in-95 duration-200 origin-bottom-left">
<button onClick={() => openExternal("https://ko-fi.com/afkarxyz")} className="flex items-center gap-2 px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground rounded-sm transition-colors text-left w-full">
<img src={KofiLogo} className="h-4 w-4" alt="Ko-fi"/>
Support me on Ko-fi
</button>
<button onClick={() => openExternal("https://buymeacoffee.com/afkarxyz")} className="flex items-center gap-2 px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground rounded-sm transition-colors text-left w-full">
<img src={BmcLogo} className="h-4 w-4 dark:hidden" alt="BMC"/>
<img src={BmcLogoWhite} className="h-4 w-4 hidden dark:block" alt="BMC"/>
Buy Me a Coffee
</button>
</div>
</div> </div>
</div>
</div>); <div className="mt-auto flex flex-col gap-2">
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant={currentPage === "about" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "about" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("about")}>
<BadgeAlertIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>About</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
<CoffeeIcon size={20} loop={true}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Support me on Ko-fi</p>
</TooltipContent>
</Tooltip>
</div>
</div>);
} }
+7
View File
@@ -201,6 +201,7 @@ export function useDownload(region: string) {
copyright: copyright, copyright: copyright,
publisher: publisher, publisher: publisher,
use_first_artist_only: settings.useFirstArtistOnly, use_first_artist_only: settings.useFirstArtistOnly,
use_single_genre: settings.useSingleGenre,
}); });
if (response.success) { if (response.success) {
logger.success(`tidal: ${trackName} - ${artistName}`); logger.success(`tidal: ${trackName} - ${artistName}`);
@@ -242,6 +243,7 @@ export function useDownload(region: string) {
spotify_total_discs: spotifyTotalDiscs, spotify_total_discs: spotifyTotalDiscs,
copyright: copyright, copyright: copyright,
publisher: publisher, publisher: publisher,
use_single_genre: settings.useSingleGenre,
}); });
if (response.success) { if (response.success) {
logger.success(`amazon: ${trackName} - ${artistName}`); logger.success(`amazon: ${trackName} - ${artistName}`);
@@ -283,6 +285,7 @@ export function useDownload(region: string) {
spotify_total_discs: spotifyTotalDiscs, spotify_total_discs: spotifyTotalDiscs,
copyright: copyright, copyright: copyright,
publisher: publisher, publisher: publisher,
use_single_genre: settings.useSingleGenre,
}); });
if (response.success) { if (response.success) {
logger.success(`qobuz: ${trackName} - ${artistName}`); logger.success(`qobuz: ${trackName} - ${artistName}`);
@@ -337,6 +340,7 @@ export function useDownload(region: string) {
spotify_total_discs: spotifyTotalDiscs, spotify_total_discs: spotifyTotalDiscs,
copyright: copyright, copyright: copyright,
publisher: publisher, publisher: publisher,
use_single_genre: settings.useSingleGenre,
}); });
if (!singleServiceResponse.success && itemID) { if (!singleServiceResponse.success && itemID) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
@@ -451,6 +455,7 @@ export function useDownload(region: string) {
copyright: copyright, copyright: copyright,
publisher: publisher, publisher: publisher,
use_first_artist_only: settings.useFirstArtistOnly, use_first_artist_only: settings.useFirstArtistOnly,
use_single_genre: settings.useSingleGenre,
}); });
if (response.success) { if (response.success) {
return response; return response;
@@ -490,6 +495,7 @@ export function useDownload(region: string) {
copyright: copyright, copyright: copyright,
publisher: publisher, publisher: publisher,
use_first_artist_only: settings.useFirstArtistOnly, use_first_artist_only: settings.useFirstArtistOnly,
use_single_genre: settings.useSingleGenre,
}); });
if (response.success) { if (response.success) {
return response; return response;
@@ -530,6 +536,7 @@ export function useDownload(region: string) {
copyright: copyright, copyright: copyright,
publisher: publisher, publisher: publisher,
use_first_artist_only: settings.useFirstArtistOnly, use_first_artist_only: settings.useFirstArtistOnly,
use_single_genre: settings.useSingleGenre,
}); });
if (response.success) { if (response.success) {
return response; return response;
+3
View File
@@ -13,6 +13,9 @@ export async function fetchSpotifyMetadata(url: string, batch: boolean = true, d
} }
export async function downloadTrack(request: DownloadRequest): Promise<DownloadResponse> { export async function downloadTrack(request: DownloadRequest): Promise<DownloadResponse> {
const req = new main.DownloadRequest(request); const req = new main.DownloadRequest(request);
if (request.use_single_genre !== undefined) {
(req as any).use_single_genre = request.use_single_genre;
}
return await DownloadTrack(req); return await DownloadTrack(req);
} }
export async function checkHealth(): Promise<HealthResponse> { export async function checkHealth(): Promise<HealthResponse> {
+6 -1
View File
@@ -31,6 +31,7 @@ export interface Settings {
createPlaylistFolder: boolean; createPlaylistFolder: boolean;
createM3u8File: boolean; createM3u8File: boolean;
useFirstArtistOnly: boolean; useFirstArtistOnly: boolean;
useSingleGenre: boolean;
} }
export const FOLDER_PRESETS: Record<FolderPreset, { export const FOLDER_PRESETS: Record<FolderPreset, {
label: string; label: string;
@@ -111,7 +112,8 @@ export const DEFAULT_SETTINGS: Settings = {
spotFetchAPIUrl: "https://spotify.afkarxyz.fun/api", spotFetchAPIUrl: "https://spotify.afkarxyz.fun/api",
createPlaylistFolder: true, createPlaylistFolder: true,
createM3u8File: false, createM3u8File: false,
useFirstArtistOnly: false useFirstArtistOnly: false,
useSingleGenre: false
}; };
export const FONT_OPTIONS: { export const FONT_OPTIONS: {
value: FontFamily; value: FontFamily;
@@ -309,6 +311,9 @@ export async function loadSettings(): Promise<Settings> {
if (!('useFirstArtistOnly' in parsed)) { if (!('useFirstArtistOnly' in parsed)) {
parsed.useFirstArtistOnly = false; parsed.useFirstArtistOnly = false;
} }
if (!('useSingleGenre' in parsed)) {
parsed.useSingleGenre = false;
}
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed }; cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
return cachedSettings!; return cachedSettings!;
} }
+1
View File
@@ -138,6 +138,7 @@ export interface DownloadRequest {
publisher?: string; publisher?: string;
spotify_url?: string; spotify_url?: string;
use_first_artist_only?: boolean; use_first_artist_only?: boolean;
use_single_genre?: boolean;
} }
export interface DownloadResponse { export interface DownloadResponse {
success: boolean; success: boolean;
+3 -3
View File
@@ -1,6 +1,6 @@
module spotiflac module github.com/afkarxyz/SpotiFLAC
go 1.25.5 go 1.26
require ( require (
github.com/bogem/id3v2/v2 v2.1.4 github.com/bogem/id3v2/v2 v2.1.4
@@ -12,6 +12,7 @@ require (
github.com/ulikunitz/xz v0.5.15 github.com/ulikunitz/xz v0.5.15
github.com/wailsapp/wails/v2 v2.11.0 github.com/wailsapp/wails/v2 v2.11.0
go.etcd.io/bbolt v1.4.3 go.etcd.io/bbolt v1.4.3
golang.org/x/text v0.31.0
) )
require ( require (
@@ -45,5 +46,4 @@ require (
golang.org/x/crypto v0.45.0 // indirect golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
) )
+16
View File
@@ -2,8 +2,11 @@ package main
import ( import (
"embed" "embed"
"encoding/json"
"log" "log"
"github.com/afkarxyz/SpotiFLAC/backend"
"github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver" "github.com/wailsapp/wails/v2/pkg/options/assetserver"
@@ -13,8 +16,21 @@ import (
//go:embed all:frontend/dist //go:embed all:frontend/dist
var assets embed.FS var assets embed.FS
//go:embed wails.json
var wailsJSON []byte
func main() { func main() {
type wailsInfo struct {
Info struct {
ProductVersion string `json:"productVersion"`
} `json:"info"`
}
var config wailsInfo
if err := json.Unmarshal(wailsJSON, &config); err == nil && config.Info.ProductVersion != "" {
backend.AppVersion = config.Info.ProductVersion
}
app := NewApp() app := NewApp()
err := wails.Run(&options.App{ err := wails.Run(&options.App{
+1 -1
View File
@@ -12,7 +12,7 @@
}, },
"info": { "info": {
"productName": "SpotiFLAC", "productName": "SpotiFLAC",
"productVersion": "7.0.9", "productVersion": "7.1.0",
"copyright": "© 2026 afkarxyz" "copyright": "© 2026 afkarxyz"
}, },
"wailsjsdir": "./frontend", "wailsjsdir": "./frontend",