v7.1.0
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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._
|
||||||
|
|
||||||
[](https://ko-fi.com/afkarxyz)
|
[](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]
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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®ion=%s", trackID, formatID, region)
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
|
||||||
req.Header.Set("Referer", "https://jumo-dl.pages.dev/")
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &result); err != nil {
|
|
||||||
|
|
||||||
decoded := decodeXOR(body)
|
|
||||||
if err := json.Unmarshal([]byte(decoded), &result); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to parse JSON (plain or XOR): %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.URL != "" {
|
|
||||||
return result.URL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("URL not found in Jumo response")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, quality string) (string, error) {
|
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
@@ -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
@@ -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
@@ -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
@@ -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 |
@@ -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,7 +246,7 @@ ${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);
|
||||||
@@ -270,7 +275,7 @@ ${contextContent}`;
|
|||||||
</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>
|
||||||
|
|
||||||
@@ -281,7 +286,7 @@ ${contextContent}`;
|
|||||||
<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>
|
||||||
@@ -294,15 +299,23 @@ ${contextContent}`;
|
|||||||
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">
|
||||||
|
Album
|
||||||
|
</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="Playlist" className="flex-1 cursor-pointer" aria-label="Toggle playlist">
|
||||||
|
Playlist
|
||||||
|
</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="Artist" className="flex-1 cursor-pointer" aria-label="Toggle artist">
|
||||||
|
Artist
|
||||||
|
</ToggleGroupItem>
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Spotify URL</Label>
|
<Label>Spotify URL</Label>
|
||||||
<Input placeholder="https://open.spotify.com/..." value={spotifyUrl} onChange={e => setSpotifyUrl(e.target.value)}/>
|
<Input placeholder="https://open.spotify.com/..." value={spotifyUrl} onChange={(e) => setSpotifyUrl(e.target.value)}/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -321,11 +334,11 @@ ${contextContent}`;
|
|||||||
<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>Description</Label>
|
<Label>Description</Label>
|
||||||
<Textarea className="h-56 resize-none" placeholder="Describe your feature request..." value={featureDesc} onChange={e => setFeatureDesc(e.target.value)}/>
|
<Textarea className="h-56 resize-none" placeholder="Describe your feature request..." value={featureDesc} onChange={(e) => setFeatureDesc(e.target.value)}/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 flex-col">
|
<div className="space-y-2 flex-col">
|
||||||
<Label>Use Case</Label>
|
<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)}/>
|
<Textarea className="h-56 resize-none" placeholder="How would this feature be useful?" value={useCase} onChange={(e) => setUseCase(e.target.value)}/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 flex-col">
|
<div className="space-y-2 flex-col">
|
||||||
<Label>Additional Context</Label>
|
<Label>Additional Context</Label>
|
||||||
@@ -349,8 +362,12 @@ ${contextContent}`;
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{faqs.map((faq, index) => (<div key={index} className="space-y-2">
|
{faqs.map((faq, index) => (<div key={index} className="space-y-2">
|
||||||
<h3 className="font-medium text-base text-foreground/90">{faq.q}</h3>
|
<h3 className="font-medium text-base text-foreground/90">
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">{faq.a}</p>
|
{faq.q}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
{faq.a}
|
||||||
|
</p>
|
||||||
</div>))}
|
</div>))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -372,93 +389,175 @@ ${contextContent}`;
|
|||||||
</Card>
|
</Card>
|
||||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer flex-1" onClick={() => openExternal("https://spotubedl.com/")}>
|
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer flex-1" onClick={() => openExternal("https://spotubedl.com/")}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2"><img src={SpotubeDLIcon} className="h-5 w-5" alt="SpotubeDL"/> SpotubeDL</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
<CardDescription>Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus with High Quality.</CardDescription>
|
<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>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/SpotiDownloader")}>
|
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/SpotiDownloader")}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2"><img src={SpotiDownloaderIcon} className="h-5 w-5" alt="SpotiDownloader"/> SpotiDownloader</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
<CardDescription>Get Spotify tracks in MP3 and FLAC via spotidownloader.com</CardDescription>
|
<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>
|
</CardHeader>
|
||||||
{repoStats['SpotiDownloader'] && (<CardContent className="space-y-3">
|
{repoStats["SpotiDownloader"] && (<CardContent className="space-y-3">
|
||||||
<div className="flex flex-wrap gap-2 text-xs">
|
<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>))}
|
{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>
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
<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">
|
||||||
<span className="flex items-center gap-1"><GitFork className="h-3.5 w-3.5"/> {repoStats['SpotiDownloader'].forks}</span>
|
<Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/>{" "}
|
||||||
<span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5"/> {formatTimeAgo(repoStats['SpotiDownloader'].createdAt)}</span>
|
{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>
|
||||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
|
<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">
|
||||||
<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>
|
<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>
|
</div>
|
||||||
</CardContent>)}
|
</CardContent>)}
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/spotiverse/SpotiFLAC-Next")}>
|
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/spotiverse/SpotiFLAC-Next")}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2"><img src={SpotiFLACNextIcon} className="h-5 w-5" alt="SpotiFLAC Next"/> SpotiFLAC Next</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
<CardDescription>Get Spotify tracks in Hi-Res lossless FLACs — no account required.</CardDescription>
|
<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>
|
</CardHeader>
|
||||||
{repoStats['SpotiFLAC-Next'] && (<CardContent className="space-y-3">
|
{repoStats["SpotiFLAC-Next"] && (<CardContent className="space-y-3">
|
||||||
<div className="flex flex-wrap gap-2 text-xs">
|
<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>))}
|
{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>
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
<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">
|
||||||
<span className="flex items-center gap-1"><GitFork className="h-3.5 w-3.5"/> {repoStats['SpotiFLAC-Next'].forks}</span>
|
<Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/>{" "}
|
||||||
<span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5"/> {formatTimeAgo(repoStats['SpotiFLAC-Next'].createdAt)}</span>
|
{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>
|
||||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
|
<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">
|
||||||
<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>
|
<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>
|
</div>
|
||||||
</CardContent>)}
|
</CardContent>)}
|
||||||
</Card>
|
</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")}>
|
<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>
|
<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>
|
<CardTitle className="flex items-center gap-2">
|
||||||
<CardDescription>A GUI tool to download original-quality images and videos from Twitter/X accounts, powered by gallery-dl by @mikf</CardDescription>
|
<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>
|
</CardHeader>
|
||||||
{repoStats['Twitter-X-Media-Batch-Downloader'] && (<CardContent className="space-y-3">
|
{repoStats["Twitter-X-Media-Batch-Downloader"] && (<CardContent className="space-y-3">
|
||||||
<div className="flex flex-wrap gap-2 text-xs">
|
<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>))}
|
{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>
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
<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">
|
||||||
<span className="flex items-center gap-1"><GitFork className="h-3.5 w-3.5"/> {repoStats['Twitter-X-Media-Batch-Downloader'].forks}</span>
|
<Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/>{" "}
|
||||||
<span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5"/> {formatTimeAgo(repoStats['Twitter-X-Media-Batch-Downloader'].createdAt)}</span>
|
{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>
|
||||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
|
<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">
|
||||||
<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>
|
<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>
|
</div>
|
||||||
</CardContent>)}
|
</CardContent>)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
|
|
||||||
{activeTab === "support" && (<div className="flex flex-col items-center justify-center p-8 space-y-8">
|
{activeTab === "support" && (<div className="flex flex-col items-center justify-center p-8 space-y-8">
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<h3 className="text-2xl font-bold tracking-tight">Support Our Work</h3>
|
<h3 className="text-2xl font-bold tracking-tight">Support Me</h3>
|
||||||
<p className="text-muted-foreground max-w-[500px]">
|
<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.
|
If this software is useful and brings you value, consider
|
||||||
|
supporting the project on Ko-fi. Your support helps keep
|
||||||
|
development going.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid sm:grid-cols-2 gap-4 w-full max-w-lg">
|
<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")}>
|
<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"/>
|
<img src={KofiLogo} className="h-8 w-8 transition-transform group-hover:scale-110" alt="Ko-fi"/>
|
||||||
Support me on Ko-fi
|
Support me on Ko-fi
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button size="lg" className="h-16 text-lg font-semibold text-black gap-3 group" style={{ backgroundColor: "#ffdd00" }} onClick={() => openExternal("https://buymeacoffee.com/afkarxyz")}>
|
|
||||||
<img src={BmcLogo} className="h-6 w-6 transition-transform group-hover:scale-110" alt="Buy Me a Coffee"/>
|
|
||||||
Buy Me a Coffee
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -21,7 +18,6 @@ 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}>
|
<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 === "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")}>
|
||||||
@@ -100,7 +96,6 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="mt-auto flex flex-col gap-2">
|
<div className="mt-auto flex flex-col gap-2">
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -112,26 +107,16 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
<p>About</p>
|
<p>About</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<div className="relative group">
|
<Tooltip delayDuration={0}>
|
||||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary">
|
<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}/>
|
<CoffeeIcon size={20} loop={true}/>
|
||||||
</Button>
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
<div className="absolute left-10 bottom-0 w-4 h-full bg-transparent"/>
|
<p>Support me on Ko-fi</p>
|
||||||
|
</TooltipContent>
|
||||||
<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">
|
</Tooltip>
|
||||||
<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>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user