Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3273b7602 | |||
| d495a9851c | |||
| 6f5fd1d16e | |||
| f4b7049f4a | |||
| 4cccdcae77 | |||
| c21d08f050 | |||
| 00d3fb9212 | |||
| 7b12866334 | |||
| 1b415961cc | |||
| 74001462b4 | |||
| fdca1ab461 | |||
| 3d8ff2cedd | |||
| 9ef24f5a91 | |||
| 1314c14c59 |
+1
-2
@@ -1,3 +1,2 @@
|
|||||||
github: afkarxyz
|
github: afkarxyz
|
||||||
ko_fi: afkarxyz
|
ko_fi: afkarxyz
|
||||||
buy_me_a_coffee: afkarxyz
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -1,32 +1,24 @@
|
|||||||
[](https://github.com/afkarxyz/SpotiFLAC/releases)
|
# SpotiFLAC
|
||||||
[](https://t.me/spotiflac)
|
|
||||||
[](https://t.me/spotiflac_chat)
|
|
||||||
|
|
||||||

|
<a href="https://trendshift.io/repositories/15737" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15737" alt="afkarxyz%2FSpotiFLAC | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
[](https://t.me/spotiflac)
|
||||||
<a href="https://trendshift.io/repositories/15737" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15737" alt="afkarxyz%2FSpotiFLAC | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
[](https://t.me/spotiflac_chat)
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases)
|
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases)
|
||||||
|
|
||||||
## Screenshot
|

|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Other projects
|
## Other projects
|
||||||
|
|
||||||
### [SpotiFLAC Next](https://github.com/spotiverse/SpotiFLAC-Next)
|
### [SpotiFLAC Next](https://github.com/afkarxyz/SpotiFLAC-Next)
|
||||||
|
|
||||||
Get Spotify tracks in Hi-Res lossless FLACs — no account required.
|
Get Spotify tracks in true FLAC from Tidal, Qobuz, Amazon Music & Deezer — no account required.
|
||||||
|
|
||||||
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
|
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
|
||||||
|
|
||||||
@@ -40,6 +32,10 @@ Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus with High Quality.
|
|||||||
|
|
||||||
SpotiFLAC for Android & iOS — maintained by [@zarzet](https://github.com/zarzet)
|
SpotiFLAC for Android & iOS — maintained by [@zarzet](https://github.com/zarzet)
|
||||||
|
|
||||||
|
### [SpotiFLAC (CLI)](https://github.com/Nizarberyan/SpotiFLAC)
|
||||||
|
|
||||||
|
SpotiFLAC for command-line environments — maintained by [@Nizarberyan](https://github.com/Nizarberyan)
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
### Is this software free?
|
### Is this software free?
|
||||||
@@ -76,14 +72,13 @@ _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)
|
||||||
[](https://www.buymeacoffee.com/afkarxyz)
|
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
||||||
|
|
||||||
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
|
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music or any other streaming service.
|
||||||
|
|
||||||
You are solely responsible for:
|
You are solely responsible for:
|
||||||
|
|
||||||
@@ -95,8 +90,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) · [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]
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,14 +5,17 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"spotiflac/backend"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/afkarxyz/SpotiFLAC/backend"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,10 +53,11 @@ func (a *App) shutdown(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SpotifyMetadataRequest struct {
|
type SpotifyMetadataRequest struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Batch bool `json:"batch"`
|
Batch bool `json:"batch"`
|
||||||
Delay float64 `json:"delay"`
|
Delay float64 `json:"delay"`
|
||||||
Timeout float64 `json:"timeout"`
|
Timeout float64 `json:"timeout"`
|
||||||
|
Separator string `json:"separator,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownloadRequest struct {
|
type DownloadRequest struct {
|
||||||
@@ -88,6 +92,9 @@ 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"`
|
||||||
|
EmbedGenre bool `json:"embed_genre,omitempty"`
|
||||||
|
Separator string `json:"separator,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownloadResponse struct {
|
type DownloadResponse struct {
|
||||||
@@ -339,7 +346,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
if req.EmbedLyrics {
|
if req.EmbedLyrics {
|
||||||
go func() {
|
go func() {
|
||||||
client := backend.NewLyricsClient()
|
client := backend.NewLyricsClient()
|
||||||
resp, _, err := client.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, req.Duration)
|
resp, _, err := client.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, req.AlbumName, req.Duration)
|
||||||
if err == nil && resp != nil && len(resp.Lines) > 0 {
|
if err == nil && resp != nil && len(resp.Lines) > 0 {
|
||||||
lrc := client.ConvertToLRC(resp, req.TrackName, req.ArtistName)
|
lrc := client.ConvertToLRC(resp, req.TrackName, req.ArtistName)
|
||||||
lyricsChan <- lrc
|
lyricsChan <- lrc
|
||||||
@@ -366,25 +373,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, req.EmbedGenre)
|
||||||
} 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, req.EmbedGenre)
|
||||||
}
|
}
|
||||||
|
|
||||||
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, req.EmbedGenre)
|
||||||
} 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, req.EmbedGenre)
|
||||||
}
|
}
|
||||||
} 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, req.EmbedGenre)
|
||||||
} 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, req.EmbedGenre)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,7 +404,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, req.EmbedGenre)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return DownloadResponse{
|
return DownloadResponse{
|
||||||
@@ -478,11 +485,17 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
|
|
||||||
meta, err := backend.GetTrackMetadata(fPath)
|
meta, err := backend.GetTrackMetadata(fPath)
|
||||||
if err == nil && meta != nil {
|
if err == nil && meta != nil {
|
||||||
quality = fmt.Sprintf("%d-bit/%.1fkHz", meta.BitsPerSample, float64(meta.SampleRate)/1000.0)
|
if meta.BitsPerSample > 0 {
|
||||||
|
quality = fmt.Sprintf("%d-bit/%.1fkHz", meta.BitsPerSample, float64(meta.SampleRate)/1000.0)
|
||||||
|
} else if meta.Bitrate > 0 {
|
||||||
|
quality = fmt.Sprintf("%dkbps/%.1fkHz", meta.Bitrate/1000, float64(meta.SampleRate)/1000.0)
|
||||||
|
} else if meta.SampleRate > 0 {
|
||||||
|
quality = fmt.Sprintf("%.1fkHz", float64(meta.SampleRate)/1000.0)
|
||||||
|
}
|
||||||
d := int(meta.Duration)
|
d := int(meta.Duration)
|
||||||
durationStr = fmt.Sprintf("%d:%02d", d/60, d%60)
|
durationStr = fmt.Sprintf("%d:%02d", d/60, d%60)
|
||||||
} else {
|
} else {
|
||||||
|
fmt.Printf("[History] Failed to get metadata for %s: %v\n", fPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
item := backend.HistoryItem{
|
item := backend.HistoryItem{
|
||||||
@@ -493,7 +506,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
DurationStr: durationStr,
|
DurationStr: durationStr,
|
||||||
CoverURL: cover,
|
CoverURL: cover,
|
||||||
Quality: quality,
|
Quality: quality,
|
||||||
Format: format,
|
Format: strings.ToUpper(format),
|
||||||
Path: fPath,
|
Path: fPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,6 +548,18 @@ func (a *App) OpenFolder(path string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) OpenConfigFolder() error {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get home directory: %v", err)
|
||||||
|
}
|
||||||
|
configDir := filepath.Join(homeDir, ".spotiflac")
|
||||||
|
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create config directory: %v", err)
|
||||||
|
}
|
||||||
|
return backend.OpenFolderInExplorer(configDir)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) SelectFolder(defaultPath string) (string, error) {
|
func (a *App) SelectFolder(defaultPath string) (string, error) {
|
||||||
return backend.SelectFolderDialog(a.ctx, defaultPath)
|
return backend.SelectFolderDialog(a.ctx, defaultPath)
|
||||||
}
|
}
|
||||||
@@ -647,6 +672,52 @@ func (a *App) ExportFailedDownloads() (string, error) {
|
|||||||
return fmt.Sprintf("Successfully exported %d failed downloads to %s", count, path), nil
|
return fmt.Sprintf("Successfully exported %d failed downloads to %s", count, path), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
|
||||||
|
var checkURL string
|
||||||
|
if apiType == "tidal" {
|
||||||
|
checkURL = fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL)
|
||||||
|
} else if apiType == "qobuz" {
|
||||||
|
checkURL = fmt.Sprintf("%s/api/stream?trackId=360735657&format_id=27", apiURL)
|
||||||
|
} else if apiType == "qbz" {
|
||||||
|
checkURL = fmt.Sprintf("%s/api/track/360735657?quality=27", apiURL)
|
||||||
|
} else if apiType == "amazon" {
|
||||||
|
checkURL = fmt.Sprintf("%s/status", apiURL)
|
||||||
|
} else {
|
||||||
|
checkURL = apiURL
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
req, err := http.NewRequest("GET", checkURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
|
||||||
|
maxRetries := 3
|
||||||
|
for i := 0; i < maxRetries; i++ {
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err == nil {
|
||||||
|
statusCode := resp.StatusCode
|
||||||
|
if apiType == "amazon" && statusCode == 200 {
|
||||||
|
body, readErr := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if readErr == nil && strings.Contains(string(body), `"amazonMusic":"up"`) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resp.Body.Close()
|
||||||
|
if statusCode == 200 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if i < maxRetries-1 {
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) Quit() {
|
func (a *App) Quit() {
|
||||||
|
|
||||||
panic("quit")
|
panic("quit")
|
||||||
@@ -1075,23 +1146,6 @@ func (a *App) RenameFileTo(oldPath, newName string) error {
|
|||||||
return os.Rename(oldPath, newPath)
|
return os.Rename(oldPath, newPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) UploadImage(filePath string) (string, error) {
|
|
||||||
return backend.UploadToSendNow(filePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) UploadImageBytes(filename string, base64Data string) (string, error) {
|
|
||||||
|
|
||||||
if idx := strings.Index(base64Data, ","); idx != -1 {
|
|
||||||
base64Data = base64Data[idx+1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := base64.StdEncoding.DecodeString(base64Data)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to decode base64: %v", err)
|
|
||||||
}
|
|
||||||
return backend.UploadBytesToSendNow(filename, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) SelectImageVideo() ([]string, error) {
|
func (a *App) SelectImageVideo() ([]string, error) {
|
||||||
return backend.SelectImageVideoDialog(a.ctx)
|
return backend.SelectImageVideoDialog(a.ctx)
|
||||||
}
|
}
|
||||||
@@ -1357,10 +1411,6 @@ func (a *App) CheckFFmpegInstalled() (bool, error) {
|
|||||||
return backend.IsFFmpegInstalled()
|
return backend.IsFFmpegInstalled()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) GetOSInfo() (string, error) {
|
|
||||||
return backend.GetOSInfo()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) CreateM3U8File(m3u8Name string, outputDir string, filePaths []string) error {
|
func (a *App) CreateM3U8File(m3u8Name string, outputDir string, filePaths []string) error {
|
||||||
if len(filePaths) == 0 {
|
if len(filePaths) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+34
-15
@@ -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://amzn.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, embedGenre 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 {
|
||||||
if spotifyURL != "" {
|
ISRC string
|
||||||
|
Metadata Metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
metaChan := make(chan mbResult, 1)
|
||||||
|
if embedGenre && 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, embedGenre); err == nil {
|
||||||
|
res.Metadata = fetchedMeta
|
||||||
|
fmt.Println("✓ MusicBrainz metadata fetched")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metaChan <- res
|
||||||
}()
|
}()
|
||||||
} else {
|
} 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)
|
||||||
@@ -346,6 +363,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
|
|||||||
newFilename = strings.ReplaceAll(newFilename, "{album}", safeAlbum)
|
newFilename = strings.ReplaceAll(newFilename, "{album}", safeAlbum)
|
||||||
newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist)
|
newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist)
|
||||||
newFilename = strings.ReplaceAll(newFilename, "{year}", year)
|
newFilename = strings.ReplaceAll(newFilename, "{year}", year)
|
||||||
|
newFilename = strings.ReplaceAll(newFilename, "{date}", SanitizeFilename(spotifyReleaseDate))
|
||||||
|
|
||||||
if spotifyDiscNumber > 0 {
|
if spotifyDiscNumber > 0 {
|
||||||
newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber))
|
newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber))
|
||||||
@@ -428,6 +446,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 +473,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, embedGenre bool,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
|
|
||||||
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
|
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
|
||||||
@@ -462,5 +481,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, embedGenre)
|
||||||
}
|
}
|
||||||
|
|||||||
+95
-24
@@ -4,6 +4,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-flac/go-flac"
|
"github.com/go-flac/go-flac"
|
||||||
mewflac "github.com/mewkiz/flac"
|
mewflac "github.com/mewkiz/flac"
|
||||||
@@ -17,6 +21,7 @@ type AnalysisResult struct {
|
|||||||
BitsPerSample uint8 `json:"bits_per_sample"`
|
BitsPerSample uint8 `json:"bits_per_sample"`
|
||||||
TotalSamples uint64 `json:"total_samples"`
|
TotalSamples uint64 `json:"total_samples"`
|
||||||
Duration float64 `json:"duration"`
|
Duration float64 `json:"duration"`
|
||||||
|
Bitrate int `json:"bit_rate"`
|
||||||
BitDepth string `json:"bit_depth"`
|
BitDepth string `json:"bit_depth"`
|
||||||
DynamicRange float64 `json:"dynamic_range"`
|
DynamicRange float64 `json:"dynamic_range"`
|
||||||
PeakAmplitude float64 `json:"peak_amplitude"`
|
PeakAmplitude float64 `json:"peak_amplitude"`
|
||||||
@@ -168,40 +173,106 @@ func GetTrackMetadata(filepath string) (*AnalysisResult, error) {
|
|||||||
return nil, fmt.Errorf("file does not exist: %s", filepath)
|
return nil, fmt.Errorf("file does not exist: %s", filepath)
|
||||||
}
|
}
|
||||||
|
|
||||||
fileInfo, err := os.Stat(filepath)
|
return GetMetadataWithFFprobe(filepath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetMetadataWithFFprobe(filePath string) (*AnalysisResult, error) {
|
||||||
|
ffprobePath, err := GetFFprobePath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get file info: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
f, err := flac.ParseFile(filepath)
|
for i := 0; i < 5; i++ {
|
||||||
|
if f, err := os.Open(filePath); err == nil {
|
||||||
|
f.Close()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"-v", "error",
|
||||||
|
"-select_streams", "a:0",
|
||||||
|
"-show_entries", "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample,duration,bit_rate",
|
||||||
|
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||||
|
filePath,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(ffprobePath, args...)
|
||||||
|
setHideWindow(cmd)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
|
return nil, fmt.Errorf("ffprobe failed: %w - %s", err, string(output))
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &AnalysisResult{
|
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||||
FilePath: filepath,
|
if len(lines) < 4 {
|
||||||
FileSize: fileInfo.Size(),
|
return nil, fmt.Errorf("unexpected ffprobe output: %s", string(output))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(f.Meta) > 0 {
|
res := &AnalysisResult{
|
||||||
streamInfo := f.Meta[0]
|
FilePath: filePath,
|
||||||
if streamInfo.Type == flac.StreamInfo {
|
}
|
||||||
data := streamInfo.Data
|
|
||||||
if len(data) >= 18 {
|
|
||||||
result.SampleRate = uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4
|
|
||||||
result.BitsPerSample = ((data[12]&0x01)<<4 | data[13]>>4) + 1
|
|
||||||
result.TotalSamples = uint64(data[13]&0x0F)<<32 |
|
|
||||||
uint64(data[14])<<24 |
|
|
||||||
uint64(data[15])<<16 |
|
|
||||||
uint64(data[16])<<8 |
|
|
||||||
uint64(data[17])
|
|
||||||
|
|
||||||
if result.SampleRate > 0 {
|
if info, err := os.Stat(filePath); err == nil {
|
||||||
result.Duration = float64(result.TotalSamples) / float64(result.SampleRate)
|
res.FileSize = info.Size()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
infoMap := make(map[string]string)
|
||||||
|
|
||||||
|
args = []string{
|
||||||
|
"-v", "error",
|
||||||
|
"-select_streams", "a:0",
|
||||||
|
"-show_entries", "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample,duration,bit_rate",
|
||||||
|
"-of", "default=noprint_wrappers=0",
|
||||||
|
filePath,
|
||||||
|
}
|
||||||
|
cmd = exec.Command(ffprobePath, args...)
|
||||||
|
setHideWindow(cmd)
|
||||||
|
output, err = cmd.CombinedOutput()
|
||||||
|
if err == nil {
|
||||||
|
lines = strings.Split(string(output), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.Contains(line, "=") {
|
||||||
|
parts := strings.SplitN(line, "=", 2)
|
||||||
|
infoMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample)
|
|
||||||
return result, nil
|
if val, ok := infoMap["sample_rate"]; ok {
|
||||||
|
s, _ := strconv.Atoi(val)
|
||||||
|
res.SampleRate = uint32(s)
|
||||||
|
}
|
||||||
|
if val, ok := infoMap["channels"]; ok {
|
||||||
|
c, _ := strconv.Atoi(val)
|
||||||
|
res.Channels = uint8(c)
|
||||||
|
}
|
||||||
|
if val, ok := infoMap["duration"]; ok {
|
||||||
|
d, _ := strconv.ParseFloat(val, 64)
|
||||||
|
res.Duration = d
|
||||||
|
}
|
||||||
|
if val, ok := infoMap["bit_rate"]; ok && val != "N/A" {
|
||||||
|
br, _ := strconv.Atoi(val)
|
||||||
|
res.Bitrate = br
|
||||||
|
}
|
||||||
|
|
||||||
|
bits := 0
|
||||||
|
if val, ok := infoMap["bits_per_raw_sample"]; ok && val != "N/A" {
|
||||||
|
bits, _ = strconv.Atoi(val)
|
||||||
|
}
|
||||||
|
if bits == 0 {
|
||||||
|
if val, ok := infoMap["bits_per_sample"]; ok && val != "N/A" {
|
||||||
|
bits, _ = strconv.Atoi(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.BitsPerSample = uint8(bits)
|
||||||
|
if bits > 0 {
|
||||||
|
res.BitDepth = fmt.Sprintf("%d-bit", bits)
|
||||||
|
} else {
|
||||||
|
res.BitDepth = "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -83,6 +83,7 @@ func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDa
|
|||||||
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
||||||
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
||||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||||
|
filename = strings.ReplaceAll(filename, "{date}", sanitizeFilename(releaseDate))
|
||||||
|
|
||||||
if discNumber > 0 {
|
if discNumber > 0 {
|
||||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||||
@@ -116,7 +117,7 @@ func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return filename + ".cover.jpg"
|
return filename + ".jpg"
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertSmallToMedium(imageURL string) string {
|
func convertSmallToMedium(imageURL string) string {
|
||||||
|
|||||||
+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/145.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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,6 +332,7 @@ func GenerateFilename(metadata *AudioMetadata, format string, ext string) string
|
|||||||
result = strings.ReplaceAll(result, "{album}", sanitizeFilenameForRename(metadata.Album))
|
result = strings.ReplaceAll(result, "{album}", sanitizeFilenameForRename(metadata.Album))
|
||||||
result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist))
|
result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist))
|
||||||
result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year))
|
result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year))
|
||||||
|
result = strings.ReplaceAll(result, "{date}", sanitizeFilenameForRename(metadata.Year))
|
||||||
|
|
||||||
if metadata.TrackNumber > 0 {
|
if metadata.TrackNumber > 0 {
|
||||||
result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber))
|
result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber))
|
||||||
|
|||||||
+28
-1
@@ -1,7 +1,9 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -33,6 +35,7 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas
|
|||||||
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
||||||
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
||||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||||
|
filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate))
|
||||||
filename = strings.ReplaceAll(filename, "{playlist}", safePlaylist)
|
filename = strings.ReplaceAll(filename, "{playlist}", safePlaylist)
|
||||||
filename = strings.ReplaceAll(filename, "{creator}", safeCreator)
|
filename = strings.ReplaceAll(filename, "{creator}", safeCreator)
|
||||||
|
|
||||||
@@ -132,10 +135,34 @@ func GetFirstArtist(artistString string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NormalizePath(folderPath string) string {
|
func NormalizePath(folderPath string) string {
|
||||||
|
|
||||||
return strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
|
return strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetSeparator() string {
|
||||||
|
dir, err := GetFFmpegDir()
|
||||||
|
if err != nil {
|
||||||
|
return "; "
|
||||||
|
}
|
||||||
|
configPath := filepath.Join(dir, "config.json")
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return "; "
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &settings); err == nil {
|
||||||
|
if sep, ok := settings["separator"].(string); ok {
|
||||||
|
if sep == "comma" {
|
||||||
|
return ", "
|
||||||
|
}
|
||||||
|
if sep == "semicolon" {
|
||||||
|
return "; "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "; "
|
||||||
|
}
|
||||||
|
|
||||||
func SanitizeFolderPath(folderPath string) string {
|
func SanitizeFolderPath(folderPath string) string {
|
||||||
|
|
||||||
normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
|
normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
|
||||||
|
|||||||
+102
-50
@@ -1,7 +1,6 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -71,14 +70,16 @@ func NewLyricsClient() *LyricsClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string, duration int) (*LyricsResponse, error) {
|
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName, albumName 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))
|
||||||
|
|
||||||
|
if albumName != "" {
|
||||||
|
apiURL = fmt.Sprintf("%s&album_name=%s", apiURL, url.QueryEscape(albumName))
|
||||||
|
}
|
||||||
|
|
||||||
if duration > 0 {
|
if duration > 0 {
|
||||||
apiURL = fmt.Sprintf("%s&duration=%d", apiURL, duration)
|
apiURL = fmt.Sprintf("%s&duration=%d", apiURL, duration)
|
||||||
}
|
}
|
||||||
@@ -103,6 +104,10 @@ func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string, dur
|
|||||||
return nil, fmt.Errorf("failed to parse LRCLIB response: %v", err)
|
return nil, fmt.Errorf("failed to parse LRCLIB response: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if lrcLibResp.SyncedLyrics == "" && lrcLibResp.PlainLyrics == "" {
|
||||||
|
return nil, fmt.Errorf("LRCLIB returned empty lyrics")
|
||||||
|
}
|
||||||
|
|
||||||
return c.convertLRCLibToLyricsResponse(&lrcLibResp), nil
|
return c.convertLRCLibToLyricsResponse(&lrcLibResp), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,9 +171,10 @@ 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)
|
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9zZWFyY2g/cT0=")
|
apiURL := fmt.Sprintf("https://lrclib.net/api/search?artist_name=%s&track_name=%s",
|
||||||
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(query))
|
url.QueryEscape(artistName),
|
||||||
|
url.QueryEscape(trackName))
|
||||||
|
|
||||||
resp, err := c.httpClient.Get(apiURL)
|
resp, err := c.httpClient.Get(apiURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -194,21 +200,32 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string)
|
|||||||
return nil, fmt.Errorf("no results found")
|
return nil, fmt.Errorf("no results found")
|
||||||
}
|
}
|
||||||
|
|
||||||
var best *LRCLibResponse
|
var bestSynced *LRCLibResponse
|
||||||
|
var bestPlain *LRCLibResponse
|
||||||
for i := range results {
|
for i := range results {
|
||||||
if results[i].SyncedLyrics != "" {
|
if results[i].SyncedLyrics != "" && bestSynced == nil {
|
||||||
best = &results[i]
|
bestSynced = &results[i]
|
||||||
break
|
|
||||||
}
|
}
|
||||||
if best == nil && results[i].PlainLyrics != "" {
|
if results[i].PlainLyrics != "" && bestPlain == nil {
|
||||||
best = &results[i]
|
bestPlain = &results[i]
|
||||||
|
}
|
||||||
|
if bestSynced != nil {
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
best := bestSynced
|
||||||
|
if best == nil {
|
||||||
|
best = bestPlain
|
||||||
|
}
|
||||||
if best == nil {
|
if best == nil {
|
||||||
best = &results[0]
|
best = &results[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if best.SyncedLyrics == "" && best.PlainLyrics == "" {
|
||||||
|
return nil, fmt.Errorf("no lyrics found in search results")
|
||||||
|
}
|
||||||
|
|
||||||
return c.convertLRCLibToLyricsResponse(best), nil
|
return c.convertLRCLibToLyricsResponse(best), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,35 +241,88 @@ func simplifyTrackName(name string) string {
|
|||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, duration int) (*LyricsResponse, string, error) {
|
func isSynced(resp *LyricsResponse) bool {
|
||||||
|
return resp != nil && !resp.Error && resp.SyncType == "LINE_SYNCED" && len(resp.Lines) > 0
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := c.FetchLyricsWithMetadata(trackName, artistName, duration)
|
func hasLyrics(resp *LyricsResponse) bool {
|
||||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
return resp != nil && !resp.Error && len(resp.Lines) > 0
|
||||||
return resp, "LRCLIB", nil
|
}
|
||||||
}
|
|
||||||
fmt.Printf(" LRCLIB exact: %v\n", err)
|
|
||||||
|
|
||||||
resp, err = c.FetchLyricsFromLRCLibSearch(trackName, artistName)
|
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName, albumName string, duration int) (*LyricsResponse, string, error) {
|
||||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
|
||||||
return resp, "LRCLIB Search", nil
|
var unsyncedFallback *LyricsResponse
|
||||||
|
var unsyncedSource string
|
||||||
|
|
||||||
|
check := func(resp *LyricsResponse, err error, source string) (*LyricsResponse, string, bool) {
|
||||||
|
if err != nil || resp == nil || resp.Error || len(resp.Lines) == 0 {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
if isSynced(resp) {
|
||||||
|
return resp, source, true
|
||||||
|
}
|
||||||
|
|
||||||
|
if unsyncedFallback == nil {
|
||||||
|
unsyncedFallback = resp
|
||||||
|
unsyncedSource = source
|
||||||
|
}
|
||||||
|
return nil, "", false
|
||||||
}
|
}
|
||||||
fmt.Printf(" LRCLIB search: %v\n", err)
|
|
||||||
|
var resp *LyricsResponse
|
||||||
|
var src string
|
||||||
|
var found bool
|
||||||
|
|
||||||
|
resp, _ = c.FetchLyricsWithMetadata(trackName, artistName, albumName, duration)
|
||||||
|
resp, src, found = check(resp, nil, "LRCLIB")
|
||||||
|
if found {
|
||||||
|
fmt.Printf(" [LRCLIB] Synced found via exact match (with album)\n")
|
||||||
|
return resp, src, nil
|
||||||
|
}
|
||||||
|
fmt.Printf(" LRCLIB exact (with album): no synced\n")
|
||||||
|
|
||||||
|
if albumName != "" {
|
||||||
|
resp, _ = c.FetchLyricsWithMetadata(trackName, artistName, "", duration)
|
||||||
|
resp, src, found = check(resp, nil, "LRCLIB (no album)")
|
||||||
|
if found {
|
||||||
|
fmt.Printf(" [LRCLIB] Synced found via exact match (no album)\n")
|
||||||
|
return resp, src, nil
|
||||||
|
}
|
||||||
|
fmt.Printf(" LRCLIB exact (no album): no synced\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, _ = c.FetchLyricsFromLRCLibSearch(trackName, artistName)
|
||||||
|
resp, src, found = check(resp, nil, "LRCLIB Search")
|
||||||
|
if found {
|
||||||
|
fmt.Printf(" [LRCLIB] Synced found via search\n")
|
||||||
|
return resp, src, nil
|
||||||
|
}
|
||||||
|
fmt.Printf(" LRCLIB search: no synced\n")
|
||||||
|
|
||||||
simplifiedTrack := simplifyTrackName(trackName)
|
simplifiedTrack := simplifyTrackName(trackName)
|
||||||
if simplifiedTrack != trackName {
|
if simplifiedTrack != trackName {
|
||||||
fmt.Printf(" Trying simplified name: %s\n", simplifiedTrack)
|
fmt.Printf(" Trying simplified name: %s\n", simplifiedTrack)
|
||||||
|
|
||||||
resp, err = c.FetchLyricsWithMetadata(simplifiedTrack, artistName, duration)
|
resp, _ = c.FetchLyricsWithMetadata(simplifiedTrack, artistName, albumName, duration)
|
||||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
resp, src, found = check(resp, nil, "LRCLIB (simplified)")
|
||||||
return resp, "LRCLIB (simplified)", nil
|
if found {
|
||||||
|
fmt.Printf(" [LRCLIB] Synced found via simplified exact\n")
|
||||||
|
return resp, src, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err = c.FetchLyricsFromLRCLibSearch(simplifiedTrack, artistName)
|
resp, _ = c.FetchLyricsFromLRCLibSearch(simplifiedTrack, artistName)
|
||||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
resp, src, found = check(resp, nil, "LRCLIB Search (simplified)")
|
||||||
return resp, "LRCLIB Search (simplified)", nil
|
if found {
|
||||||
|
fmt.Printf(" [LRCLIB] Synced found via simplified search\n")
|
||||||
|
return resp, src, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if unsyncedFallback != nil {
|
||||||
|
fmt.Printf(" [LRCLIB] No synced found, using unsynced from: %s\n", unsyncedSource)
|
||||||
|
return unsyncedFallback, unsyncedSource + " (unsynced)", nil
|
||||||
|
}
|
||||||
|
|
||||||
return nil, "", fmt.Errorf("lyrics not found in any source")
|
return nil, "", fmt.Errorf("lyrics not found in any source")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,6 +383,7 @@ func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseD
|
|||||||
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
||||||
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
||||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||||
|
filename = strings.ReplaceAll(filename, "{date}", sanitizeFilename(releaseDate))
|
||||||
|
|
||||||
if discNumber > 0 {
|
if discNumber > 0 {
|
||||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||||
@@ -403,25 +474,6 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
|
|||||||
outputDir = NormalizePath(outputDir)
|
outputDir = NormalizePath(outputDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
safeArtist := sanitizeFilename(req.AlbumArtist)
|
|
||||||
if safeArtist == "" {
|
|
||||||
safeArtist = sanitizeFilename(req.ArtistName)
|
|
||||||
}
|
|
||||||
safeAlbum := sanitizeFilename(req.AlbumName)
|
|
||||||
|
|
||||||
if safeArtist != "" && safeAlbum != "" {
|
|
||||||
artistAlbumPath := filepath.Join(outputDir, safeArtist, safeAlbum)
|
|
||||||
if info, err := os.Stat(artistAlbumPath); err == nil && info.IsDir() {
|
|
||||||
outputDir = artistAlbumPath
|
|
||||||
} else {
|
|
||||||
|
|
||||||
artistPath := filepath.Join(outputDir, safeArtist)
|
|
||||||
if info, err := os.Stat(artistPath); err == nil && info.IsDir() {
|
|
||||||
outputDir = artistPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
return &LyricsDownloadResponse{
|
return &LyricsDownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
@@ -455,7 +507,7 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, audioDuration)
|
lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, req.AlbumName, audioDuration)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &LyricsDownloadResponse{
|
return &LyricsDownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
|
|||||||
@@ -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,154 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/text/cases"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
)
|
||||||
|
|
||||||
|
var AppVersion = "Unknown"
|
||||||
|
|
||||||
|
const musicBrainzAPIBase = "https://musicbrainz.org/ws/2"
|
||||||
|
|
||||||
|
type MusicBrainzRecordingResponse struct {
|
||||||
|
Recordings []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Length int `json:"length"`
|
||||||
|
Releases []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ReleaseGroup struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
PrimaryType string `json:"primary-type"`
|
||||||
|
} `json:"release-group"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
Media []struct {
|
||||||
|
Format string `json:"format"`
|
||||||
|
} `json:"media"`
|
||||||
|
LabelInfo []struct {
|
||||||
|
Label struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"label"`
|
||||||
|
} `json:"label-info"`
|
||||||
|
} `json:"releases"`
|
||||||
|
ArtistCredit []struct {
|
||||||
|
Artist struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"artist"`
|
||||||
|
} `json:"artist-credit"`
|
||||||
|
Tags []struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"tags"`
|
||||||
|
} `json:"recordings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchMusicBrainzMetadata(isrc, title, artist, album string, useSingleGenre bool, embedGenre bool) (Metadata, error) {
|
||||||
|
var meta Metadata
|
||||||
|
|
||||||
|
if !embedGenre {
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if isrc == "" {
|
||||||
|
return meta, fmt.Errorf("no ISRC provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf("isrc:%s", isrc)
|
||||||
|
reqURL := fmt.Sprintf("%s/recording?query=%s&fmt=json&inc=releases+artist-credits+tags+media+release-groups+labels", musicBrainzAPIBase, url.QueryEscape(query))
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", reqURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return meta, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", fmt.Sprintf("SpotiFLAC/%s ( support@exyezed.cc )", AppVersion))
|
||||||
|
|
||||||
|
var resp *http.Response
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
resp, lastErr = client.Do(req)
|
||||||
|
if lastErr == nil && resp.StatusCode == http.StatusOK {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if i < 2 {
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastErr != nil {
|
||||||
|
return meta, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
resp.Body.Close()
|
||||||
|
return meta, fmt.Errorf("MusicBrainz API returned status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var mbResp MusicBrainzRecordingResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&mbResp); err != nil {
|
||||||
|
return meta, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(mbResp.Recordings) == 0 {
|
||||||
|
return meta, fmt.Errorf("no recordings found for ISRC: %s", isrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
recording := mbResp.Recordings[0]
|
||||||
|
|
||||||
|
var genres []string
|
||||||
|
caser := cases.Title(language.English)
|
||||||
|
|
||||||
|
if useSingleGenre {
|
||||||
|
|
||||||
|
maxCount := -1
|
||||||
|
var bestTag string
|
||||||
|
|
||||||
|
for _, tag := range recording.Tags {
|
||||||
|
if tag.Count > maxCount {
|
||||||
|
maxCount = tag.Count
|
||||||
|
bestTag = tag.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bestTag != "" {
|
||||||
|
meta.Genre = caser.String(bestTag)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, tag := range recording.Tags {
|
||||||
|
|
||||||
|
genres = append(genres, caser.String(tag.Name))
|
||||||
|
}
|
||||||
|
if len(genres) > 0 {
|
||||||
|
|
||||||
|
if len(genres) > 5 {
|
||||||
|
genres = genres[:5]
|
||||||
|
}
|
||||||
|
meta.Genre = strings.Join(genres, GetSeparator())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
+32
-82
@@ -118,81 +118,15 @@ 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 {
|
func buildQobuzAPIURL(apiBase string, trackID int64, quality string) string {
|
||||||
text := string(data)
|
if strings.Contains(apiBase, "qbz.afkarxyz.fun") {
|
||||||
runes := []rune(text)
|
return fmt.Sprintf("%s%d?quality=%s", apiBase, trackID, quality)
|
||||||
result := make([]rune, len(runes))
|
|
||||||
for i, char := range runes {
|
|
||||||
key := rune((i * 17) % 128)
|
|
||||||
result[i] = char ^ 253 ^ key
|
|
||||||
}
|
}
|
||||||
return string(result)
|
return fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
|
||||||
}
|
|
||||||
|
|
||||||
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 := buildQobuzAPIURL(apiBase, trackID, quality)
|
||||||
resp, err := q.client.Get(apiURL)
|
resp, err := q.client.Get(apiURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -240,7 +174,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
|
|||||||
standardAPIs := []string{
|
standardAPIs := []string{
|
||||||
"https://dab.yeet.su/api/stream?trackId=",
|
"https://dab.yeet.su/api/stream?trackId=",
|
||||||
"https://dabmusic.xyz/api/stream?trackId=",
|
"https://dabmusic.xyz/api/stream?trackId=",
|
||||||
"https://qobuz.squid.wtf/api/download-music?track_id=",
|
"https://qbz.afkarxyz.fun/api/track/",
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadFunc := func(qual string) (string, error) {
|
downloadFunc := func(qual string) (string, error) {
|
||||||
@@ -261,13 +195,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] })
|
||||||
|
|
||||||
@@ -399,6 +326,7 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t
|
|||||||
filename = strings.ReplaceAll(filename, "{album}", album)
|
filename = strings.ReplaceAll(filename, "{album}", album)
|
||||||
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
|
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
|
||||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||||
|
filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate))
|
||||||
|
|
||||||
if discNumber > 0 {
|
if discNumber > 0 {
|
||||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||||
@@ -433,7 +361,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, embedGenre bool) (string, error) {
|
||||||
var deezerISRC string
|
var deezerISRC string
|
||||||
if spotifyID != "" {
|
if spotifyID != "" {
|
||||||
songlinkClient := NewSongLinkClient()
|
songlinkClient := NewSongLinkClient()
|
||||||
@@ -446,12 +374,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, embedGenre)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool) (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, embedGenre bool) (string, error) {
|
||||||
fmt.Printf("Fetching track info for ISRC: %s\n", deezerISRC)
|
fmt.Printf("Fetching track info for ISRC: %s\n", deezerISRC)
|
||||||
|
|
||||||
|
metaChan := make(chan Metadata, 1)
|
||||||
|
if embedGenre && deezerISRC != "" {
|
||||||
|
go func() {
|
||||||
|
fmt.Println("Fetching MusicBrainz metadata...")
|
||||||
|
if fetchedMeta, err := FetchMusicBrainzMetadata(deezerISRC, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
|
||||||
|
fmt.Println("✓ MusicBrainz metadata fetched")
|
||||||
|
metaChan <- fetchedMeta
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
||||||
|
metaChan <- Metadata{}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
close(metaChan)
|
||||||
|
}
|
||||||
|
|
||||||
if outputDir != "." {
|
if outputDir != "." {
|
||||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
return "", fmt.Errorf("failed to create output directory: %w", err)
|
return "", fmt.Errorf("failed to create output directory: %w", err)
|
||||||
@@ -532,6 +476,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 +503,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 {
|
||||||
|
|||||||
+11
-15
@@ -1,7 +1,6 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -29,9 +28,11 @@ type TrackAvailability struct {
|
|||||||
Tidal bool `json:"tidal"`
|
Tidal bool `json:"tidal"`
|
||||||
Amazon bool `json:"amazon"`
|
Amazon bool `json:"amazon"`
|
||||||
Qobuz bool `json:"qobuz"`
|
Qobuz bool `json:"qobuz"`
|
||||||
|
Deezer bool `json:"deezer"`
|
||||||
TidalURL string `json:"tidal_url,omitempty"`
|
TidalURL string `json:"tidal_url,omitempty"`
|
||||||
AmazonURL string `json:"amazon_url,omitempty"`
|
AmazonURL string `json:"amazon_url,omitempty"`
|
||||||
QobuzURL string `json:"qobuz_url,omitempty"`
|
QobuzURL string `json:"qobuz_url,omitempty"`
|
||||||
|
DeezerURL string `json:"deezer_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSongLinkClient() *SongLinkClient {
|
func NewSongLinkClient() *SongLinkClient {
|
||||||
@@ -71,11 +72,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 +199,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 {
|
||||||
@@ -284,6 +281,8 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string) (*TrackAv
|
|||||||
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
deezerURL := deezerLink.URL
|
deezerURL := deezerLink.URL
|
||||||
|
availability.Deezer = true
|
||||||
|
availability.DeezerURL = deezerURL
|
||||||
|
|
||||||
deezerISRC, err := getDeezerISRC(deezerURL)
|
deezerISRC, err := getDeezerISRC(deezerURL)
|
||||||
if err == nil && deezerISRC != "" {
|
if err == nil && deezerISRC != "" {
|
||||||
@@ -299,8 +298,7 @@ func checkQobuzAvailability(isrc string) bool {
|
|||||||
client := &http.Client{Timeout: 10 * time.Second}
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
appID := "798273057"
|
appID := "798273057"
|
||||||
|
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/track/search?query=%s&limit=1&app_id=%s", isrc, appID)
|
||||||
searchURL := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, appID)
|
|
||||||
|
|
||||||
resp, err := client.Get(searchURL)
|
resp, err := client.Get(searchURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -352,11 +350,9 @@ func (s *SongLinkClient) GetDeezerURLFromSpotify(spotifyTrackID string) (string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
||||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
|
||||||
|
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(spotifyURL))
|
||||||
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", apiURL, nil)
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+16
-52
@@ -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 {
|
||||||
@@ -701,7 +665,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
|||||||
for _, artist := range albumArtists {
|
for _, artist := range albumArtists {
|
||||||
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
||||||
}
|
}
|
||||||
albumArtistsString = strings.Join(albumArtistNames, ", ")
|
albumArtistsString = strings.Join(albumArtistNames, GetSeparator())
|
||||||
}
|
}
|
||||||
if albumArtistsString == "" {
|
if albumArtistsString == "" {
|
||||||
albumArtistsString = getString(albumUnionData, "artists")
|
albumArtistsString = getString(albumUnionData, "artists")
|
||||||
@@ -717,7 +681,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
|||||||
for _, artist := range albumArtists {
|
for _, artist := range albumArtists {
|
||||||
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
||||||
}
|
}
|
||||||
albumArtistsString = strings.Join(albumArtistNames, ", ")
|
albumArtistsString = strings.Join(albumArtistNames, GetSeparator())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,13 +715,13 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
|||||||
for _, artist := range artists {
|
for _, artist := range artists {
|
||||||
artistNames = append(artistNames, getString(artist, "name"))
|
artistNames = append(artistNames, getString(artist, "name"))
|
||||||
}
|
}
|
||||||
artistsString := strings.Join(artistNames, ", ")
|
artistsString := strings.Join(artistNames, GetSeparator())
|
||||||
|
|
||||||
copyrightTexts := []string{}
|
copyrightTexts := []string{}
|
||||||
for _, item := range copyrightInfo {
|
for _, item := range copyrightInfo {
|
||||||
copyrightTexts = append(copyrightTexts, getString(item, "text"))
|
copyrightTexts = append(copyrightTexts, getString(item, "text"))
|
||||||
}
|
}
|
||||||
copyrightString := strings.Join(copyrightTexts, ", ")
|
copyrightString := strings.Join(copyrightTexts, GetSeparator())
|
||||||
|
|
||||||
discNumber := int(getFloat64(trackData, "discNumber"))
|
discNumber := int(getFloat64(trackData, "discNumber"))
|
||||||
if discNumber == 0 {
|
if discNumber == 0 {
|
||||||
@@ -850,7 +814,7 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
|||||||
for _, artist := range artists {
|
for _, artist := range artists {
|
||||||
artistNames = append(artistNames, getString(artist, "name"))
|
artistNames = append(artistNames, getString(artist, "name"))
|
||||||
}
|
}
|
||||||
albumArtistsString := strings.Join(artistNames, ", ")
|
albumArtistsString := strings.Join(artistNames, GetSeparator())
|
||||||
|
|
||||||
coverObj := extractCoverImage(getMap(albumData, "coverArt"))
|
coverObj := extractCoverImage(getMap(albumData, "coverArt"))
|
||||||
var cover interface{}
|
var cover interface{}
|
||||||
@@ -911,7 +875,7 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
|||||||
for _, artist := range trackArtists {
|
for _, artist := range trackArtists {
|
||||||
trackArtistNames = append(trackArtistNames, getString(artist, "name"))
|
trackArtistNames = append(trackArtistNames, getString(artist, "name"))
|
||||||
}
|
}
|
||||||
trackArtistsString := strings.Join(trackArtistNames, ", ")
|
trackArtistsString := strings.Join(trackArtistNames, GetSeparator())
|
||||||
|
|
||||||
trackURI := getString(track, "uri")
|
trackURI := getString(track, "uri")
|
||||||
trackID := ""
|
trackID := ""
|
||||||
@@ -1111,7 +1075,7 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
|||||||
for _, artist := range trackArtists {
|
for _, artist := range trackArtists {
|
||||||
trackArtistNames = append(trackArtistNames, getString(artist, "name"))
|
trackArtistNames = append(trackArtistNames, getString(artist, "name"))
|
||||||
}
|
}
|
||||||
artistsString := strings.Join(trackArtistNames, ", ")
|
artistsString := strings.Join(trackArtistNames, GetSeparator())
|
||||||
|
|
||||||
trackDurationMs := getFloat64(getMap(trackData, "trackDuration"), "totalMilliseconds")
|
trackDurationMs := getFloat64(getMap(trackData, "trackDuration"), "totalMilliseconds")
|
||||||
durationObj := extractDuration(trackDurationMs)
|
durationObj := extractDuration(trackDurationMs)
|
||||||
@@ -1157,7 +1121,7 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
|||||||
for _, artist := range albumArtists {
|
for _, artist := range albumArtists {
|
||||||
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
||||||
}
|
}
|
||||||
albumArtistsString = strings.Join(albumArtistNames, ", ")
|
albumArtistsString = strings.Join(albumArtistNames, GetSeparator())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1550,7 +1514,7 @@ func FilterSearch(data map[string]interface{}) map[string]interface{} {
|
|||||||
for _, artist := range trackArtists {
|
for _, artist := range trackArtists {
|
||||||
trackArtistNames = append(trackArtistNames, getString(artist, "name"))
|
trackArtistNames = append(trackArtistNames, getString(artist, "name"))
|
||||||
}
|
}
|
||||||
trackArtistsString := strings.Join(trackArtistNames, ", ")
|
trackArtistsString := strings.Join(trackArtistNames, GetSeparator())
|
||||||
|
|
||||||
durationString := getString(trackDuration, "formatted")
|
durationString := getString(trackDuration, "formatted")
|
||||||
|
|
||||||
@@ -1622,7 +1586,7 @@ func FilterSearch(data map[string]interface{}) map[string]interface{} {
|
|||||||
for _, artist := range albumArtists {
|
for _, artist := range albumArtists {
|
||||||
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
||||||
}
|
}
|
||||||
albumArtistsString := strings.Join(albumArtistNames, ", ")
|
albumArtistsString := strings.Join(albumArtistNames, GetSeparator())
|
||||||
|
|
||||||
dateInfo := getMap(album, "date")
|
dateInfo := getMap(album, "date")
|
||||||
var year interface{}
|
var year interface{}
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
//go:build !windows
|
|
||||||
|
|
||||||
package backend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetOSInfo() (string, error) {
|
|
||||||
osType := runtime.GOOS
|
|
||||||
arch := runtime.GOARCH
|
|
||||||
|
|
||||||
switch osType {
|
|
||||||
case "darwin":
|
|
||||||
out, err := exec.Command("sw_vers", "-productVersion").Output()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Sprintf("macOS %s", arch), nil
|
|
||||||
}
|
|
||||||
version := strings.TrimSpace(string(out))
|
|
||||||
return fmt.Sprintf("macOS %s (%s)", version, arch), nil
|
|
||||||
|
|
||||||
case "linux":
|
|
||||||
out, err := exec.Command("cat", "/etc/os-release").Output()
|
|
||||||
if err == nil {
|
|
||||||
lines := strings.Split(string(out), "\n")
|
|
||||||
for _, line := range lines {
|
|
||||||
if strings.HasPrefix(line, "PRETTY_NAME=") {
|
|
||||||
name := strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), "\"")
|
|
||||||
return fmt.Sprintf("%s (%s)", name, arch), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("Linux %s", arch), nil
|
|
||||||
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("%s %s", osType, arch), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package backend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetOSInfo() (string, error) {
|
|
||||||
arch := runtime.GOARCH
|
|
||||||
|
|
||||||
cmd := exec.Command("wmic", "os", "get", "Caption,Version", "/value")
|
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
|
||||||
out, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
cmdVer := exec.Command("cmd", "/c", "ver")
|
|
||||||
cmdVer.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
|
||||||
outVer, errVer := cmdVer.Output()
|
|
||||||
if errVer != nil {
|
|
||||||
return fmt.Sprintf("Windows %s", arch), nil
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(outVer)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := strings.Split(string(out), "\n")
|
|
||||||
var caption, version string
|
|
||||||
for _, line := range lines {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if strings.HasPrefix(line, "Caption=") {
|
|
||||||
caption = strings.TrimPrefix(line, "Caption=")
|
|
||||||
} else if strings.HasPrefix(line, "Version=") {
|
|
||||||
version = strings.TrimPrefix(line, "Version=")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if caption != "" && version != "" {
|
|
||||||
return fmt.Sprintf("%s (%s, %s)", caption, version, arch), nil
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(out)), nil
|
|
||||||
}
|
|
||||||
+63
-20
@@ -79,11 +79,13 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
|
|||||||
|
|
||||||
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
||||||
apis := []string{
|
apis := []string{
|
||||||
"https://triton.squid.wtf",
|
|
||||||
"https://hifi-one.spotisaver.net",
|
"https://hifi-one.spotisaver.net",
|
||||||
"https://hifi-two.spotisaver.net",
|
"https://hifi-two.spotisaver.net",
|
||||||
|
"https://eu-central.monochrome.tf",
|
||||||
|
"https://us-west.monochrome.tf",
|
||||||
|
"https://api.monochrome.tf",
|
||||||
|
"https://monochrome-api.samidy.com",
|
||||||
"https://tidal.kinoplus.online",
|
"https://tidal.kinoplus.online",
|
||||||
"https://tidal-api.binimum.org",
|
|
||||||
}
|
}
|
||||||
return apis, nil
|
return apis, nil
|
||||||
}
|
}
|
||||||
@@ -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, embedGenre bool) (string, error) {
|
||||||
if outputDir != "." {
|
if outputDir != "." {
|
||||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
return "", fmt.Errorf("directory error: %w", err)
|
return "", fmt.Errorf("directory error: %w", err)
|
||||||
@@ -500,9 +502,15 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isrcChan := make(chan string, 1)
|
type mbResult struct {
|
||||||
if spotifyURL != "" {
|
ISRC string
|
||||||
|
Metadata Metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
metaChan := make(chan mbResult, 1)
|
||||||
|
if embedGenre && 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, embedGenre); err == nil {
|
||||||
|
res.Metadata = fetchedMeta
|
||||||
|
fmt.Println("✓ MusicBrainz metadata fetched")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metaChan <- res
|
||||||
}()
|
}()
|
||||||
} else {
|
} 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, embedGenre bool) (string, error) {
|
||||||
apis, err := t.GetAvailableAPIs()
|
apis, err := t.GetAvailableAPIs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("no APIs available for fallback: %w", err)
|
return "", fmt.Errorf("no APIs available for fallback: %w", err)
|
||||||
@@ -638,9 +660,15 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isrcChan := make(chan string, 1)
|
type mbResultFallback struct {
|
||||||
if spotifyURL != "" {
|
ISRC string
|
||||||
|
Metadata Metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
metaChan := make(chan mbResultFallback, 1)
|
||||||
|
if embedGenre && 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, embedGenre); err == nil {
|
||||||
|
res.Metadata = fetchedMeta
|
||||||
|
fmt.Println("✓ MusicBrainz metadata fetched")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metaChan <- res
|
||||||
}()
|
}()
|
||||||
} else {
|
} 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, embedGenre bool) (string, error) {
|
||||||
|
|
||||||
tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
|
tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("songlink couldn't find Tidal URL: %w", err)
|
return "", fmt.Errorf("songlink couldn't find Tidal URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, 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, embedGenre)
|
||||||
}
|
}
|
||||||
|
|
||||||
type SegmentTemplate struct {
|
type SegmentTemplate struct {
|
||||||
@@ -981,6 +1023,7 @@ func buildTidalFilename(title, artist, album, albumArtist, releaseDate string, t
|
|||||||
filename = strings.ReplaceAll(filename, "{album}", album)
|
filename = strings.ReplaceAll(filename, "{album}", album)
|
||||||
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
|
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
|
||||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||||
|
filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate))
|
||||||
|
|
||||||
if discNumber > 0 {
|
if discNumber > 0 {
|
||||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||||
|
|||||||
@@ -1,217 +0,0 @@
|
|||||||
package backend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"mime/multipart"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type SendNowResponse []struct {
|
|
||||||
FileCode string `json:"file_code"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func UploadToSendNow(filePath string) (string, error) {
|
|
||||||
file, err := os.Open(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to open file: %v", err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
return uploadToService(filepath.Base(filePath), file)
|
|
||||||
}
|
|
||||||
|
|
||||||
func UploadBytesToSendNow(filename string, data []byte) (string, error) {
|
|
||||||
return uploadToService(filename, bytes.NewReader(data))
|
|
||||||
}
|
|
||||||
|
|
||||||
func uploadToService(filename string, fileReader io.Reader) (string, error) {
|
|
||||||
body := &bytes.Buffer{}
|
|
||||||
writer := multipart.NewWriter(body)
|
|
||||||
|
|
||||||
fields := map[string]string{
|
|
||||||
"sess_id": "",
|
|
||||||
"utype": "anon",
|
|
||||||
"hidden": "",
|
|
||||||
"enableemail": "",
|
|
||||||
"link_rcpt": "",
|
|
||||||
"link_pass": "",
|
|
||||||
"file_expire_time": "",
|
|
||||||
"file_expire_unit": "DAY",
|
|
||||||
"file_max_dl": "1",
|
|
||||||
"file_public": "1",
|
|
||||||
"keepalive": "1",
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, val := range fields {
|
|
||||||
if err := writer.WriteField(key, val); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
part, err := writer.CreateFormFile("file_0", filename)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if _, err := io.Copy(part, fileReader); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
writer.Close()
|
|
||||||
|
|
||||||
uploadURL, err := getUploadURL()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get upload server: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", uploadURL, body)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
|
||||||
req.Header.Set("Origin", "https://send.now")
|
|
||||||
req.Header.Set("Referer", "https://send.now/")
|
|
||||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 60 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("upload failed: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
respBytes, _ := io.ReadAll(resp.Body)
|
|
||||||
return "", fmt.Errorf("server error %d: %s", resp.StatusCode, string(respBytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
respBytes, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
var result SendNowResponse
|
|
||||||
if err := json.Unmarshal(respBytes, &result); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to parse response: %v, raw: %s", err, string(respBytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(result) == 0 || result[0].FileCode == "" {
|
|
||||||
return "", fmt.Errorf("invalid response format")
|
|
||||||
}
|
|
||||||
|
|
||||||
fileCode := result[0].FileCode
|
|
||||||
downloadLink := fmt.Sprintf("https://send.now/%s", fileCode)
|
|
||||||
|
|
||||||
ext := strings.ToLower(filepath.Ext(filename))
|
|
||||||
if ext == ".mp4" || ext == ".mov" || ext == ".mkv" || ext == ".webm" || ext == ".avi" {
|
|
||||||
return fmt.Sprintf("[Video](%s)", downloadLink), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return fetchDirectImageLink(downloadLink)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getUploadURL() (string, error) {
|
|
||||||
req, err := http.NewRequest("GET", "https://send.now/", nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return "", fmt.Errorf("failed to fetch main page: status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyBytes, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
body := string(bodyBytes)
|
|
||||||
|
|
||||||
re := regexp.MustCompile(`action=["'](https://[^"']+/cgi-bin/upload\.cgi\?upload_type=file[^"']*)["']`)
|
|
||||||
matches := re.FindStringSubmatch(body)
|
|
||||||
if len(matches) > 1 {
|
|
||||||
return matches[1], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
reFallback := regexp.MustCompile(`action=["'](https://[^"']+/cgi-bin/upload\.cgi)`)
|
|
||||||
matchesFallback := reFallback.FindStringSubmatch(body)
|
|
||||||
if len(matchesFallback) > 1 {
|
|
||||||
return matchesFallback[1] + "?upload_type=file&utype=anon", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("upload URL not found in main page")
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchDirectImageLink(url string) (string, error) {
|
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
htmlBytes, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
htmlStr := string(htmlBytes)
|
|
||||||
|
|
||||||
reFullRes := regexp.MustCompile(`(?i)<a[^>]+href=["']([^"']+)["'][^>]*title=["']Open image on new tab["']`)
|
|
||||||
matchesFull := reFullRes.FindStringSubmatch(htmlStr)
|
|
||||||
if len(matchesFull) > 1 {
|
|
||||||
return fmt.Sprintf("", matchesFull[1]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
reClipboard := regexp.MustCompile(`(?s)data-clipboard-text=['"]<a href="[^"]+".*?><img src="([^"]+)"`)
|
|
||||||
matches := reClipboard.FindStringSubmatch(htmlStr)
|
|
||||||
if len(matches) > 1 {
|
|
||||||
return fmt.Sprintf("", matches[1]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
reImg := regexp.MustCompile(`(?i)<img[^>]+src=["']([^"']*?\.send\.now/i/[^"']+)["']`)
|
|
||||||
matchesImg := reImg.FindStringSubmatch(htmlStr)
|
|
||||||
if len(matchesImg) > 1 {
|
|
||||||
return fmt.Sprintf("", matchesImg[1]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
reAnchor := regexp.MustCompile(`(?i)<a[^>]+href=["']([^"']+\.(?:jpg|jpeg|png|gif|webp))["']`)
|
|
||||||
matchesAnchor := reAnchor.FindStringSubmatch(htmlStr)
|
|
||||||
if len(matchesAnchor) > 1 {
|
|
||||||
return fmt.Sprintf("", matchesAnchor[1]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
reGeneric := regexp.MustCompile(`(?i)<img[^>]+src=["']([^"']+\.(?:jpg|jpeg|png|gif|webp))["']`)
|
|
||||||
matchesGeneric := reGeneric.FindAllStringSubmatch(htmlStr, -1)
|
|
||||||
for _, match := range matchesGeneric {
|
|
||||||
if len(match) > 1 {
|
|
||||||
link := match[1]
|
|
||||||
|
|
||||||
if !regexp.MustCompile(`(?i)(logo|icon|button|assets)`).MatchString(filepath.Base(link)) {
|
|
||||||
return fmt.Sprintf("", link), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("[View File](%s)", url), nil
|
|
||||||
}
|
|
||||||
+16
-15
@@ -26,32 +26,33 @@
|
|||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.575.0",
|
||||||
"motion": "^12.26.2",
|
"motion": "^12.34.3",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react-dom": "^19.2.3",
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.1.18"
|
"tailwindcss": "^4.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/node": "^25.0.8",
|
"@types/node": "^25.3.0",
|
||||||
"@types/react": "^19.2.8",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^10.0.2",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.26",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.0.0",
|
"globals": "^17.3.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.53.0",
|
"typescript-eslint": "^8.56.1",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
9fee02ec6592ede9ade4b36d56bd4d6d
|
867c45db7982e126a7249d80210f23be
|
||||||
Generated
+1786
-1245
File diff suppressed because it is too large
Load Diff
@@ -336,7 +336,7 @@ function App() {
|
|||||||
if ("track" in metadata.metadata) {
|
if ("track" in metadata.metadata) {
|
||||||
const { track } = metadata.metadata;
|
const { track } = metadata.metadata;
|
||||||
const trackId = track.spotify_id || "";
|
const trackId = track.spotify_id || "";
|
||||||
return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(trackId)} isFailed={download.failedTracks.has(trackId)} isSkipped={download.skippedTracks.has(trackId)} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} checkingAvailability={availability.checkingTrackId === track.spotify_id} availability={availability.availabilityMap.get(track.spotify_id || "")} downloadingCover={cover.downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)} downloadedCover={cover.downloadedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} failedCover={cover.failedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} skippedCover={cover.skippedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} onDownload={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, track.album_name, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, track.album_name, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder} onBack={metadata.resetMetadata}/>);
|
return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(trackId)} isFailed={download.failedTracks.has(trackId)} isSkipped={download.skippedTracks.has(trackId)} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} checkingAvailability={availability.checkingTrackId === track.spotify_id} availability={availability.availabilityMap.get(track.spotify_id || "")} downloadingCover={cover.downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)} downloadedCover={cover.downloadedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} failedCover={cover.failedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} skippedCover={cover.skippedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} onDownload={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, undefined, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, undefined, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder} onBack={metadata.resetMetadata}/>);
|
||||||
}
|
}
|
||||||
if ("album_info" in metadata.metadata) {
|
if ("album_info" in metadata.metadata) {
|
||||||
const { album_info, track_list } = metadata.metadata;
|
const { album_info, track_list } = metadata.metadata;
|
||||||
@@ -415,7 +415,7 @@ function App() {
|
|||||||
case "debug":
|
case "debug":
|
||||||
return <DebugLoggerPage />;
|
return <DebugLoggerPage />;
|
||||||
case "about":
|
case "about":
|
||||||
return <AboutPage version={CURRENT_VERSION}/>;
|
return <AboutPage />;
|
||||||
case "history":
|
case "history":
|
||||||
return <HistoryPage onHistorySelect={(cachedData) => {
|
return <HistoryPage onHistorySelect={(cachedData) => {
|
||||||
metadata.loadFromCache(cachedData);
|
metadata.loadFromCache(cachedData);
|
||||||
|
|||||||
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 |
Binary file not shown.
|
After Width: | Height: | Size: 903 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -1,13 +1,8 @@
|
|||||||
import { useState, useEffect } from "react";
|
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 { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Star, GitFork, Clock, Download, Blocks, Heart, Copy, CircleCheck } from "lucide-react";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
|
||||||
import { Bug, Lightbulb, ExternalLink, Star, GitFork, Clock, Download, CircleHelp, Blocks, Heart } from "lucide-react";
|
|
||||||
import 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,67 +10,17 @@ 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/ko-fi.gif";
|
||||||
import KofiLogo from "@/assets/kofi_symbol.svg";
|
import KofiSvg from "@/assets/kofi_symbol.svg";
|
||||||
|
import UsdtBarcode from "@/assets/usdt.jpg";
|
||||||
import { langColors } from "@/assets/github-lang-colors";
|
import { langColors } from "@/assets/github-lang-colors";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
export function AboutPage() {
|
||||||
import { DragDropMedia } from "./DragDropTextarea";
|
const [activeTab, setActiveTab] = useState<"projects" | "support">("projects");
|
||||||
interface AboutPageProps {
|
|
||||||
version: string;
|
|
||||||
}
|
|
||||||
export function AboutPage({ version }: AboutPageProps) {
|
|
||||||
const [os, setOs] = useState("Unknown");
|
|
||||||
const [location, setLocation] = useState("Unknown");
|
|
||||||
const [activeTab, setActiveTab] = useState<"bug_report" | "feature_request" | "faq" | "projects" | "support">("bug_report");
|
|
||||||
const [bugType, setBugType] = useState("Track");
|
|
||||||
const [problem, setProblem] = useState("");
|
|
||||||
const [spotifyUrl, setSpotifyUrl] = useState("");
|
|
||||||
const [bugContext, setBugContext] = useState("");
|
|
||||||
const [featureDesc, setFeatureDesc] = useState("");
|
|
||||||
const [useCase, setUseCase] = useState("");
|
|
||||||
const [featureContext, setFeatureContext] = useState("");
|
|
||||||
const [repoStats, setRepoStats] = useState<Record<string, any>>({});
|
const [repoStats, setRepoStats] = useState<Record<string, any>>({});
|
||||||
|
const [copiedUsdt, setCopiedUsdt] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchOS = async () => {
|
|
||||||
try {
|
|
||||||
const info = await GetOSInfo();
|
|
||||||
setOs(info);
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
const userAgent = window.navigator.userAgent;
|
|
||||||
if (userAgent.indexOf("Win") !== -1)
|
|
||||||
setOs("Windows");
|
|
||||||
else if (userAgent.indexOf("Mac") !== -1)
|
|
||||||
setOs("macOS");
|
|
||||||
else if (userAgent.indexOf("Linux") !== -1)
|
|
||||||
setOs("Linux");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchOS();
|
|
||||||
const fetchLocation = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('https://ipapi.co/json/');
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
const city = data.city || '';
|
|
||||||
const region = data.region || '';
|
|
||||||
const country = data.country_name || '';
|
|
||||||
const parts = [city, region, country].filter(Boolean);
|
|
||||||
setLocation(parts.join(', ') || 'Unknown');
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
||||||
setLocation(timezone);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
||||||
setLocation(timezone);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
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 +32,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 +46,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) {
|
||||||
@@ -116,10 +61,14 @@ export function AboutPage({ version }: AboutPageProps) {
|
|||||||
const languages = await langsRes.json();
|
const languages = await langsRes.json();
|
||||||
let totalDownloads = 0;
|
let totalDownloads = 0;
|
||||||
let latestDownloads = 0;
|
let latestDownloads = 0;
|
||||||
|
let latestVersion = "";
|
||||||
if (releases.length > 0) {
|
if (releases.length > 0) {
|
||||||
latestDownloads = releases[0].assets?.reduce((sum: number, asset: any) => sum + (asset.download_count || 0), 0) || 0;
|
latestVersion = releases[0].tag_name || "";
|
||||||
|
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 +81,8 @@ export function AboutPage({ version }: AboutPageProps) {
|
|||||||
createdAt: repoData.created_at,
|
createdAt: repoData.created_at,
|
||||||
totalDownloads,
|
totalDownloads,
|
||||||
latestDownloads,
|
latestDownloads,
|
||||||
languages: topLangs
|
latestVersion,
|
||||||
|
languages: topLangs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,28 +100,6 @@ export function AboutPage({ version }: AboutPageProps) {
|
|||||||
};
|
};
|
||||||
fetchRepoStats();
|
fetchRepoStats();
|
||||||
}, []);
|
}, []);
|
||||||
const faqs = [
|
|
||||||
{
|
|
||||||
q: "Is this software free?",
|
|
||||||
a: "Yes. This software is completely free. You do not need an account, login, or subscription. All you need is an internet connection."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
q: "Where does the audio come from?",
|
|
||||||
a: "The audio is fetched using third-party APIs."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
q: "Why does metadata fetching sometimes fail?",
|
|
||||||
a: "This usually happens because your IP address has been rate-limited. You can wait and try again later, or use a VPN to bypass the rate limit."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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."
|
|
||||||
}
|
|
||||||
];
|
|
||||||
const formatTimeAgo = (dateString: string): string => {
|
const formatTimeAgo = (dateString: string): string => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const updated = new Date(dateString);
|
const updated = new Date(dateString);
|
||||||
@@ -179,13 +107,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,269 +126,257 @@ 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 = () => {
|
return (<div className="flex flex-col space-y-4">
|
||||||
const title = activeTab === "bug_report"
|
<div className="flex items-center justify-between shrink-0">
|
||||||
? `[Bug Report] ${problem.substring(0, 50)}${problem.length > 50 ? "..." : ""}`
|
<h2 className="text-2xl font-bold tracking-tight">About</h2>
|
||||||
: `[Feature Request] ${featureDesc.substring(0, 50)}${featureDesc.length > 50 ? "..." : ""}`;
|
</div>
|
||||||
let bodyContent = "";
|
|
||||||
if (activeTab === "bug_report") {
|
|
||||||
const contextContent = bugContext.trim() ? bugContext.trim() : "Type here or send screenshot/recording";
|
|
||||||
bodyContent = `### [Bug Report]
|
|
||||||
|
|
||||||
#### Problem
|
<div className="flex gap-2 border-b shrink-0">
|
||||||
${problem || "Type here"}
|
<Button variant={activeTab === "projects" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("projects")} className="rounded-b-none">
|
||||||
|
<Blocks className="h-4 w-4"/>
|
||||||
|
Other Projects
|
||||||
|
</Button>
|
||||||
|
<Button variant={activeTab === "support" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("support")} className="rounded-b-none">
|
||||||
|
<Heart className="h-4 w-4"/>
|
||||||
|
Support Me
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
#### Type
|
<div className="flex-1 min-h-0">
|
||||||
${bugType}
|
|
||||||
|
|
||||||
#### Spotify URL
|
|
||||||
${spotifyUrl || "Type here"}
|
|
||||||
|
|
||||||
#### Additional Context
|
{activeTab === "projects" && (<div className="p-1 pr-2">
|
||||||
${contextContent}
|
<div className="grid gap-2 grid-cols-4">
|
||||||
|
<div className="flex flex-col gap-2 h-full">
|
||||||
#### Environment
|
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer flex-1" onClick={() => openExternal("https://exyezed.cc/")}>
|
||||||
- SpotiFLAC Version: ${version}
|
<CardHeader>
|
||||||
- OS: ${os}
|
<CardTitle>Browser Extensions & Scripts</CardTitle>
|
||||||
- Location: ${location}`;
|
<CardDescription className="flex gap-3 pt-2">
|
||||||
}
|
<img src={AudioTTSProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="AudioTTS Pro"/>
|
||||||
else {
|
<img src={ChatGPTTTSIcon} className="h-8 w-8 rounded-md shadow-sm" alt="ChatGPT TTS"/>
|
||||||
const contextContent = featureContext.trim() ? featureContext.trim() : "Type here or send screenshot/recording";
|
<img src={XProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="X Pro"/>
|
||||||
bodyContent = `### [Feature Request]
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
#### Description
|
</Card>
|
||||||
${featureDesc || "Type here"}
|
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer flex-1" onClick={() => openExternal("https://spotubedl.com/")}>
|
||||||
|
<CardHeader>
|
||||||
#### Use Case
|
<CardTitle className="flex items-center gap-2">
|
||||||
${useCase || "Type here"}
|
<img src={SpotubeDLIcon} className="h-5 w-5" alt="SpotubeDL"/>{" "}
|
||||||
|
SpotubeDL
|
||||||
#### Additional Context
|
</CardTitle>
|
||||||
${contextContent}`;
|
<CardDescription>
|
||||||
}
|
Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus
|
||||||
const params = new URLSearchParams({
|
with High Quality.
|
||||||
title: title,
|
</CardDescription>
|
||||||
body: bodyContent
|
</CardHeader>
|
||||||
});
|
</Card>
|
||||||
const url = `https://github.com/afkarxyz/SpotiFLAC/issues/new?${params.toString()}`;
|
</div>
|
||||||
openExternal(url);
|
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/SpotiDownloader")}>
|
||||||
};
|
<CardHeader>
|
||||||
return (<div className={`flex flex-col space-y-4 ${activeTab === "faq" ? "h-[calc(100vh-10rem)]" : ""}`}>
|
<div className="flex justify-between items-start mb-2">
|
||||||
<div className="flex items-center justify-between shrink-0">
|
<img src={SpotiDownloaderIcon} className="h-6 w-6 shrink-0" alt="SpotiDownloader"/>
|
||||||
<h2 className="text-2xl font-bold tracking-tight">About</h2>
|
{repoStats["SpotiDownloader"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
|
||||||
</div>
|
{repoStats["SpotiDownloader"].latestVersion}
|
||||||
|
</span>)}
|
||||||
<div className="flex gap-2 border-b shrink-0">
|
</div>
|
||||||
<Button variant={activeTab === "bug_report" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("bug_report")} className="rounded-b-none">
|
<CardTitle className="leading-tight">
|
||||||
<Bug className="h-4 w-4"/>
|
SpotiDownloader
|
||||||
Bug Report
|
</CardTitle>
|
||||||
</Button>
|
<CardDescription>
|
||||||
<Button variant={activeTab === "feature_request" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("feature_request")} className="rounded-b-none">
|
Get Spotify tracks in MP3 and FLAC via spotidownloader.com
|
||||||
<Lightbulb className="h-4 w-4"/>
|
</CardDescription>
|
||||||
Feature Request
|
</CardHeader>
|
||||||
</Button>
|
{repoStats["SpotiDownloader"] && (<CardContent className="space-y-3">
|
||||||
<Button variant={activeTab === "faq" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("faq")} className="rounded-b-none">
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
<CircleHelp className="h-4 w-4"/>
|
{repoStats["SpotiDownloader"].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
|
||||||
FAQ
|
backgroundColor: getLangColor(lang) + "20",
|
||||||
</Button>
|
color: getLangColor(lang),
|
||||||
<Button variant={activeTab === "projects" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("projects")} className="rounded-b-none">
|
}}>
|
||||||
<Blocks className="h-4 w-4"/>
|
{lang}
|
||||||
Other Projects
|
</span>))}
|
||||||
</Button>
|
|
||||||
<Button variant={activeTab === "support" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("support")} className="rounded-b-none">
|
|
||||||
<Heart className="h-4 w-4"/>
|
|
||||||
Support Us
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`flex-1 min-h-0 ${activeTab === "faq" ? "overflow-hidden" : ""}`}>
|
|
||||||
{activeTab === "bug_report" && (<div className="flex flex-col">
|
|
||||||
<div className="space-y-4 pt-4 flex flex-col">
|
|
||||||
<div className="mt-4 pr-2">
|
|
||||||
<div className="grid md:grid-cols-3 gap-6">
|
|
||||||
<div className="space-y-2 flex flex-col">
|
|
||||||
<Label>Problem</Label>
|
|
||||||
<Textarea className="h-56 resize-none" placeholder="Describe the problem..." value={problem} onChange={e => setProblem(e.target.value)}/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 flex flex-col">
|
|
||||||
<Label>Additional Context</Label>
|
|
||||||
<DragDropMedia className="min-h-[14rem]" value={bugContext} onChange={setBugContext}/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4 flex flex-col">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Type</Label>
|
|
||||||
<ToggleGroup type="single" value={bugType} onValueChange={(val) => {
|
|
||||||
if (val)
|
|
||||||
setBugType(val);
|
|
||||||
}} className="justify-start w-full cursor-pointer">
|
|
||||||
<ToggleGroupItem value="Track" className="flex-1 cursor-pointer" aria-label="Toggle track">Track</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>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Spotify URL</Label>
|
|
||||||
<Input placeholder="https://open.spotify.com/..." value={spotifyUrl} onChange={e => setSpotifyUrl(e.target.value)}/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center pt-4 shrink-0">
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
<Button className="w-[200px] cursor-pointer gap-2" onClick={handleSubmit}>
|
<span className="flex items-center gap-1">
|
||||||
<ExternalLink className="h-4 w-4"/> Create Issue on GitHub
|
<Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/>{" "}
|
||||||
</Button>
|
{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>)}
|
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
{activeTab === "feature_request" && (<div className="flex flex-col">
|
<Download className="h-3.5 w-3.5"/> TOTAL:{" "}
|
||||||
<div className="space-y-4 pt-4 flex flex-col">
|
{formatNumber(repoStats["SpotiDownloader"].totalDownloads)}
|
||||||
<div className="mt-4 pr-2">
|
</span>
|
||||||
<div className="grid md:grid-cols-3 gap-6">
|
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||||
<div className="space-y-2 flex flex-col">
|
<Download className="h-3.5 w-3.5"/> LATEST:{" "}
|
||||||
<Label>Description</Label>
|
{formatNumber(repoStats["SpotiDownloader"].latestDownloads)}
|
||||||
<Textarea className="h-56 resize-none" placeholder="Describe your feature request..." value={featureDesc} onChange={e => setFeatureDesc(e.target.value)}/>
|
</span>
|
||||||
</div>
|
|
||||||
<div className="space-y-2 flex-col">
|
|
||||||
<Label>Use Case</Label>
|
|
||||||
<Textarea className="h-56 resize-none" placeholder="How would this feature be useful?" value={useCase} onChange={e => setUseCase(e.target.value)}/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 flex-col">
|
|
||||||
<Label>Additional Context</Label>
|
|
||||||
<DragDropMedia className="min-h-[14rem]" value={featureContext} onChange={setFeatureContext}/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center pt-4 shrink-0">
|
</CardContent>)}
|
||||||
<Button className="w-[200px] cursor-pointer gap-2" onClick={handleSubmit}>
|
</Card>
|
||||||
<ExternalLink className="h-4 w-4"/> Create Issue on GitHub
|
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/spotiverse/SpotiFLAC-Next")}>
|
||||||
</Button>
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<img src={SpotiFLACNextIcon} className="h-6 w-6 shrink-0" alt="SpotiFLAC Next"/>
|
||||||
|
{repoStats["SpotiFLAC-Next"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
|
||||||
|
{repoStats["SpotiFLAC-Next"].latestVersion}
|
||||||
|
</span>)}
|
||||||
|
</div>
|
||||||
|
<CardTitle className="leading-tight">
|
||||||
|
SpotiFLAC Next
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
{repoStats["SpotiFLAC-Next"] && (<CardContent className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
|
{repoStats["SpotiFLAC-Next"].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
|
||||||
|
backgroundColor: getLangColor(lang) + "20",
|
||||||
|
color: getLangColor(lang),
|
||||||
|
}}>
|
||||||
|
{lang}
|
||||||
|
</span>))}
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
{activeTab === "faq" && (<ScrollArea className="h-full">
|
<Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/>{" "}
|
||||||
<div className="p-1 pr-4">
|
{formatNumber(repoStats["SpotiFLAC-Next"].stars)}
|
||||||
<Card>
|
</span>
|
||||||
<CardHeader>
|
<span className="flex items-center gap-1">
|
||||||
<CardTitle>Frequently Asked Questions</CardTitle>
|
<GitFork className="h-3.5 w-3.5"/>{" "}
|
||||||
</CardHeader>
|
{repoStats["SpotiFLAC-Next"].forks}
|
||||||
<CardContent className="space-y-6">
|
</span>
|
||||||
{faqs.map((faq, index) => (<div key={index} className="space-y-2">
|
<span className="flex items-center gap-1">
|
||||||
<h3 className="font-medium text-base text-foreground/90">{faq.q}</h3>
|
<Clock className="h-3.5 w-3.5"/>{" "}
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">{faq.a}</p>
|
{formatTimeAgo(repoStats["SpotiFLAC-Next"].createdAt)}
|
||||||
</div>))}
|
</span>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>)}
|
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Download className="h-3.5 w-3.5"/> TOTAL:{" "}
|
||||||
|
{formatNumber(repoStats["SpotiFLAC-Next"].totalDownloads)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||||
|
<Download className="h-3.5 w-3.5"/> LATEST:{" "}
|
||||||
|
{formatNumber(repoStats["SpotiFLAC-Next"].latestDownloads)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>)}
|
||||||
|
</Card>
|
||||||
|
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader")}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<img src={XBatchDLIcon} className="h-6 w-6 shrink-0" alt="Twitter/X Media Batch Downloader"/>
|
||||||
|
{repoStats["Twitter-X-Media-Batch-Downloader"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
|
||||||
|
{repoStats["Twitter-X-Media-Batch-Downloader"].latestVersion}
|
||||||
|
</span>)}
|
||||||
|
</div>
|
||||||
|
<CardTitle className="leading-tight">
|
||||||
|
Twitter/X Media Batch Downloader
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
A GUI tool to download original-quality images and videos
|
||||||
|
from Twitter/X accounts, powered by gallery-dl by @mikf
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
{repoStats["Twitter-X-Media-Batch-Downloader"] && (<CardContent className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
|
{repoStats["Twitter-X-Media-Batch-Downloader"].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
|
||||||
|
backgroundColor: getLangColor(lang) + "20",
|
||||||
|
color: getLangColor(lang),
|
||||||
|
}}>
|
||||||
|
{lang}
|
||||||
|
</span>))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/>{" "}
|
||||||
|
{formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"].stars)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<GitFork className="h-3.5 w-3.5"/>{" "}
|
||||||
|
{repoStats["Twitter-X-Media-Batch-Downloader"].forks}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3.5 w-3.5"/>{" "}
|
||||||
|
{formatTimeAgo(repoStats["Twitter-X-Media-Batch-Downloader"]
|
||||||
|
.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Download className="h-3.5 w-3.5"/> TOTAL:{" "}
|
||||||
|
{formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"]
|
||||||
|
.totalDownloads)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||||
|
<Download className="h-3.5 w-3.5"/> LATEST:{" "}
|
||||||
|
{formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"]
|
||||||
|
.latestDownloads)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
{activeTab === "projects" && (<div className="p-1 pr-2">
|
{activeTab === "support" && (<div className="flex flex-col items-center justify-center p-4 space-y-6">
|
||||||
<div className="grid gap-2 grid-cols-4">
|
<div className="flex flex-col md:flex-row w-full max-w-3xl bg-card rounded-xl border shadow-sm">
|
||||||
<div className="flex flex-col gap-2 h-full">
|
|
||||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer flex-1" onClick={() => openExternal("https://exyezed.cc/")}>
|
<div className="flex-1 p-6 flex flex-col items-center justify-between border-b md:border-b-0 md:border-r space-y-6">
|
||||||
<CardHeader>
|
<div className="flex flex-col items-center space-y-4">
|
||||||
<CardTitle>Browser Extensions & Scripts</CardTitle>
|
<div className="h-32 flex items-center justify-center w-full relative">
|
||||||
<CardDescription className="flex gap-3 pt-2">
|
<img src={KofiLogo} className="w-72 absolute pointer-events-none" alt="Ko-fi"/>
|
||||||
<img src={AudioTTSProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="AudioTTS Pro"/>
|
</div>
|
||||||
<img src={ChatGPTTTSIcon} className="h-8 w-8 rounded-md shadow-sm" alt="ChatGPT TTS"/>
|
<h4 className="font-semibold text-foreground">Support via Ko-fi</h4>
|
||||||
<img src={XProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="X Pro"/>
|
<p className="text-sm text-muted-foreground text-center px-4">
|
||||||
</CardDescription>
|
Enjoying the project? You can support ongoing development by buying me a coffee.
|
||||||
</CardHeader>
|
</p>
|
||||||
</Card>
|
|
||||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer flex-1" onClick={() => openExternal("https://spotubedl.com/")}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2"><img src={SpotubeDLIcon} className="h-5 w-5" alt="SpotubeDL"/> SpotubeDL</CardTitle>
|
|
||||||
<CardDescription>Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus with High Quality.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/SpotiDownloader")}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2"><img src={SpotiDownloaderIcon} className="h-5 w-5" alt="SpotiDownloader"/> SpotiDownloader</CardTitle>
|
|
||||||
<CardDescription>Get Spotify tracks in MP3 and FLAC via spotidownloader.com</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
{repoStats['SpotiDownloader'] && (<CardContent className="space-y-3">
|
|
||||||
<div className="flex flex-wrap gap-2 text-xs">
|
|
||||||
{repoStats['SpotiDownloader'].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{ backgroundColor: getLangColor(lang) + '20', color: getLangColor(lang) }}>{lang}</span>))}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1"><Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/> {formatNumber(repoStats['SpotiDownloader'].stars)}</span>
|
|
||||||
<span className="flex items-center gap-1"><GitFork className="h-3.5 w-3.5"/> {repoStats['SpotiDownloader'].forks}</span>
|
|
||||||
<span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5"/> {formatTimeAgo(repoStats['SpotiDownloader'].createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
|
|
||||||
<span className="flex items-center gap-1"><Download className="h-3.5 w-3.5"/> TOTAL: {formatNumber(repoStats['SpotiDownloader'].totalDownloads)}</span>
|
|
||||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400"><Download className="h-3.5 w-3.5"/> LATEST: {formatNumber(repoStats['SpotiDownloader'].latestDownloads)}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>)}
|
|
||||||
</Card>
|
|
||||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/spotiverse/SpotiFLAC-Next")}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2"><img src={SpotiFLACNextIcon} className="h-5 w-5" alt="SpotiFLAC Next"/> SpotiFLAC Next</CardTitle>
|
|
||||||
<CardDescription>Get Spotify tracks in Hi-Res lossless FLACs — no account required.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
{repoStats['SpotiFLAC-Next'] && (<CardContent className="space-y-3">
|
|
||||||
<div className="flex flex-wrap gap-2 text-xs">
|
|
||||||
{repoStats['SpotiFLAC-Next'].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{ backgroundColor: getLangColor(lang) + '20', color: getLangColor(lang) }}>{lang}</span>))}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1"><Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/> {formatNumber(repoStats['SpotiFLAC-Next'].stars)}</span>
|
|
||||||
<span className="flex items-center gap-1"><GitFork className="h-3.5 w-3.5"/> {repoStats['SpotiFLAC-Next'].forks}</span>
|
|
||||||
<span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5"/> {formatTimeAgo(repoStats['SpotiFLAC-Next'].createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
|
|
||||||
<span className="flex items-center gap-1"><Download className="h-3.5 w-3.5"/> TOTAL: {formatNumber(repoStats['SpotiFLAC-Next'].totalDownloads)}</span>
|
|
||||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400"><Download className="h-3.5 w-3.5"/> LATEST: {formatNumber(repoStats['SpotiFLAC-Next'].latestDownloads)}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>)}
|
|
||||||
</Card>
|
|
||||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader")}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2"><img src={XBatchDLIcon} className="h-5 w-5" alt="Twitter/X Media Batch Downloader"/> Twitter/X Media Batch Downloader</CardTitle>
|
|
||||||
<CardDescription>A GUI tool to download original-quality images and videos from Twitter/X accounts, powered by gallery-dl by @mikf</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
{repoStats['Twitter-X-Media-Batch-Downloader'] && (<CardContent className="space-y-3">
|
|
||||||
<div className="flex flex-wrap gap-2 text-xs">
|
|
||||||
{repoStats['Twitter-X-Media-Batch-Downloader'].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{ backgroundColor: getLangColor(lang) + '20', color: getLangColor(lang) }}>{lang}</span>))}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1"><Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/> {formatNumber(repoStats['Twitter-X-Media-Batch-Downloader'].stars)}</span>
|
|
||||||
<span className="flex items-center gap-1"><GitFork className="h-3.5 w-3.5"/> {repoStats['Twitter-X-Media-Batch-Downloader'].forks}</span>
|
|
||||||
<span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5"/> {formatTimeAgo(repoStats['Twitter-X-Media-Batch-Downloader'].createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
|
|
||||||
<span className="flex items-center gap-1"><Download className="h-3.5 w-3.5"/> TOTAL: {formatNumber(repoStats['Twitter-X-Media-Batch-Downloader'].totalDownloads)}</span>
|
|
||||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400"><Download className="h-3.5 w-3.5"/> LATEST: {formatNumber(repoStats['Twitter-X-Media-Batch-Downloader'].latestDownloads)}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>)}
|
|
||||||
|
|
||||||
|
|
||||||
{activeTab === "support" && (<div className="flex flex-col items-center justify-center p-8 space-y-8">
|
|
||||||
<div className="text-center space-y-2">
|
|
||||||
<h3 className="text-2xl font-bold tracking-tight">Support Our Work</h3>
|
|
||||||
<p className="text-muted-foreground max-w-[500px]">
|
|
||||||
If this software is useful and brings you value, consider supporting the project by buying me a coffee. Your support helps keep development going.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button className="h-10 w-full text-sm font-semibold text-white gap-2 group bg-[#72a4f2] hover:bg-[#5f8cd6]" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
||||||
|
<img src={KofiSvg} className="w-5 h-5 shrink-0" alt="" aria-hidden="true"/>
|
||||||
|
Support on Ko-fi
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid sm:grid-cols-2 gap-4 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")}>
|
<div className="flex-1 p-6 flex flex-col items-center justify-between space-y-6">
|
||||||
<img src={KofiLogo} className="h-8 w-8 transition-transform group-hover:scale-110" alt="Ko-fi"/>
|
<div className="flex flex-col items-center space-y-4 w-full">
|
||||||
Support me on Ko-fi
|
<div className="h-32 flex items-center justify-center">
|
||||||
</Button>
|
<div className="p-2 bg-white rounded-xl shadow-sm border">
|
||||||
|
<img src={UsdtBarcode} className="w-24 h-24 object-contain" alt="USDT Barcode"/>
|
||||||
<Button size="lg" className="h-16 text-lg font-semibold text-black gap-3 group" style={{ backgroundColor: "#ffdd00" }} onClick={() => openExternal("https://buymeacoffee.com/afkarxyz")}>
|
</div>
|
||||||
<img src={BmcLogo} className="h-6 w-6 transition-transform group-hover:scale-110" alt="Buy Me a Coffee"/>
|
</div>
|
||||||
Buy Me a Coffee
|
<h4 className="font-semibold text-foreground">USDT (TRC20)</h4>
|
||||||
</Button>
|
<p className="text-sm text-muted-foreground text-center px-4">
|
||||||
|
Crypto donations are also accepted. Scan the QR code or copy the address.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
<div className="flex items-center gap-2 bg-muted/50 pl-3 pr-1.5 py-1.5 rounded-lg border w-full justify-between h-10">
|
||||||
</div>
|
<code className="text-xs font-mono text-muted-foreground truncate" title="THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs">
|
||||||
|
THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs
|
||||||
|
</code>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 hover:bg-background" onClick={() => {
|
||||||
|
navigator.clipboard.writeText("THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs");
|
||||||
|
setCopiedUsdt(true);
|
||||||
|
setTimeout(() => setCopiedUsdt(false), 500);
|
||||||
|
}}>
|
||||||
|
{copiedUsdt ? <CircleCheck className="h-3.5 w-3.5 text-green-500"/> : <Copy className="h-3.5 w-3.5"/>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
</div>);
|
</div>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||||||
import { SearchAndSort } from "./SearchAndSort";
|
import { SearchAndSort } from "./SearchAndSort";
|
||||||
import { TrackList } from "./TrackList";
|
import { TrackList } from "./TrackList";
|
||||||
import { DownloadProgress } from "./DownloadProgress";
|
import { DownloadProgress } from "./DownloadProgress";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
|
import { downloadCover } from "@/lib/api";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||||
|
import { parseTemplate, type TemplateData } from "@/lib/settings";
|
||||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||||
interface AlbumInfoProps {
|
interface AlbumInfoProps {
|
||||||
albumInfo: {
|
albumInfo: {
|
||||||
@@ -70,6 +76,65 @@ interface AlbumInfoProps {
|
|||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) {
|
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) {
|
||||||
|
const settings = getSettings();
|
||||||
|
const [downloadingAlbumCover, setDownloadingAlbumCover] = useState(false);
|
||||||
|
const handleDownloadAlbumCover = async () => {
|
||||||
|
if (!albumInfo.images)
|
||||||
|
return;
|
||||||
|
setDownloadingAlbumCover(true);
|
||||||
|
try {
|
||||||
|
const os = settings.operatingSystem;
|
||||||
|
let outputDir = settings.downloadPath;
|
||||||
|
const albumName = albumInfo.name;
|
||||||
|
const artistName = albumInfo.artists;
|
||||||
|
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||||
|
const templateData: TemplateData = {
|
||||||
|
artist: artistName?.replace(/\//g, placeholder),
|
||||||
|
album: albumName?.replace(/\//g, placeholder),
|
||||||
|
album_artist: artistName?.replace(/\//g, placeholder),
|
||||||
|
title: albumName?.replace(/\//g, placeholder),
|
||||||
|
year: albumInfo.release_date?.substring(0, 4),
|
||||||
|
date: albumInfo.release_date,
|
||||||
|
};
|
||||||
|
if (settings.folderTemplate) {
|
||||||
|
const folderPath = parseTemplate(settings.folderTemplate, templateData);
|
||||||
|
if (folderPath) {
|
||||||
|
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
||||||
|
for (const part of parts) {
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(part.replace(new RegExp(placeholder, "g"), " "), os));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const response = await downloadCover({
|
||||||
|
cover_url: albumInfo.images,
|
||||||
|
track_name: albumName,
|
||||||
|
artist_name: "",
|
||||||
|
album_name: "",
|
||||||
|
album_artist: "",
|
||||||
|
release_date: "",
|
||||||
|
output_dir: outputDir,
|
||||||
|
filename_format: "title",
|
||||||
|
track_number: false,
|
||||||
|
position: 0,
|
||||||
|
disc_number: 0,
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
if (response.already_exists)
|
||||||
|
toast.info("Cover already exists");
|
||||||
|
else
|
||||||
|
toast.success("Album cover downloaded");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.error(response.error || "Failed to download cover");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to download cover");
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setDownloadingAlbumCover(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
return (<div className="space-y-6">
|
return (<div className="space-y-6">
|
||||||
<Card className="relative">
|
<Card className="relative">
|
||||||
{onBack && (<div className="absolute top-4 right-4 z-10">
|
{onBack && (<div className="absolute top-4 right-4 z-10">
|
||||||
@@ -79,7 +144,19 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
|
|||||||
</div>)}
|
</div>)}
|
||||||
<CardContent className="px-6">
|
<CardContent className="px-6">
|
||||||
<div className="flex gap-6 items-start">
|
<div className="flex gap-6 items-start">
|
||||||
{albumInfo.images && (<img src={albumInfo.images} alt={albumInfo.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)}
|
{albumInfo.images && (<div className="relative group shrink-0 w-48 h-48">
|
||||||
|
<img src={albumInfo.images} alt={albumInfo.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity rounded-md">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="secondary" size="icon" className="h-9 w-9 shadow-lg" onClick={handleDownloadAlbumCover} disabled={downloadingAlbumCover}>
|
||||||
|
{downloadingAlbumCover ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent><p>Download Album Cover</p></TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
<div className="flex-1 space-y-4">
|
<div className="flex-1 space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-medium">Album</p>
|
<p className="text-sm font-medium">Album</p>
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { RefreshCw, CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
||||||
|
import { CheckAPIStatus } from "../../wailsjs/go/main/App";
|
||||||
|
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||||
|
interface ApiSource {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
const SOURCES: ApiSource[] = [
|
||||||
|
{ id: "tidal1", type: "tidal", name: "Tidal A", url: "https://hifi-one.spotisaver.net" },
|
||||||
|
{ id: "tidal2", type: "tidal", name: "Tidal B", url: "https://hifi-two.spotisaver.net" },
|
||||||
|
{ id: "tidal3", type: "tidal", name: "Tidal C", url: "https://eu-central.monochrome.tf" },
|
||||||
|
{ id: "tidal4", type: "tidal", name: "Tidal D", url: "https://us-west.monochrome.tf" },
|
||||||
|
{ id: "tidal5", type: "tidal", name: "Tidal E", url: "https://api.monochrome.tf" },
|
||||||
|
{ id: "tidal6", type: "tidal", name: "Tidal F", url: "https://monochrome-api.samidy.com" },
|
||||||
|
{ id: "tidal7", type: "tidal", name: "Tidal G", url: "https://tidal.kinoplus.online" },
|
||||||
|
{ id: "qobuz1", type: "qobuz", name: "Qobuz A", url: "https://dab.yeet.su" },
|
||||||
|
{ id: "qobuz2", type: "qobuz", name: "Qobuz B", url: "https://dabmusic.xyz" },
|
||||||
|
{ id: "qobuz3", type: "qbz", name: "Qobuz C", url: "https://qbz.afkarxyz.fun" },
|
||||||
|
{ id: "amazon1", type: "amazon", name: "Amazon Music", url: "https://amzn.afkarxyz.fun" },
|
||||||
|
];
|
||||||
|
export function ApiStatusTab() {
|
||||||
|
const [statuses, setStatuses] = useState<Record<string, "checking" | "online" | "offline" | "idle">>({});
|
||||||
|
const [isCheckingAll, setIsCheckingAll] = useState(false);
|
||||||
|
const checkStatus = async (sourceId: string, apiType: string, url: string) => {
|
||||||
|
setStatuses(prev => ({ ...prev, [sourceId]: "checking" }));
|
||||||
|
try {
|
||||||
|
const isOnline = await CheckAPIStatus(apiType, url);
|
||||||
|
setStatuses(prev => ({ ...prev, [sourceId]: isOnline ? "online" : "offline" }));
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
setStatuses(prev => ({ ...prev, [sourceId]: "offline" }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const checkAll = async () => {
|
||||||
|
setIsCheckingAll(true);
|
||||||
|
const promises = SOURCES.map(s => checkStatus(s.id, s.type, s.url));
|
||||||
|
await Promise.allSettled(promises);
|
||||||
|
setIsCheckingAll(false);
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
checkAll();
|
||||||
|
}, []);
|
||||||
|
return (<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Button variant="outline" onClick={checkAll} disabled={isCheckingAll} className="gap-2">
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isCheckingAll ? "animate-spin" : ""}`}/>
|
||||||
|
Refresh All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
{SOURCES.map((source) => {
|
||||||
|
const status = statuses[source.id] || "idle";
|
||||||
|
return (<div key={source.id} className="flex items-center justify-between p-4 border rounded-lg bg-card text-card-foreground shadow-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{source.type === "tidal" ? <TidalIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : source.type === "amazon" ? <AmazonIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : <QobuzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>}
|
||||||
|
<p className="font-medium leading-none">{source.name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
{status === "checking" && <Loader2 className="h-5 w-5 animate-spin text-muted-foreground"/>}
|
||||||
|
{status === "online" && <CheckCircle2 className="h-5 w-5 text-emerald-500"/>}
|
||||||
|
{status === "offline" && <XCircle className="h-5 w-5 text-destructive"/>}
|
||||||
|
{status === "idle" && <div className="h-5 w-5 rounded-full bg-muted"/>}
|
||||||
|
</div>
|
||||||
|
</div>);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
@@ -317,7 +317,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
<h2 className="text-4xl font-bold text-white">{artistInfo.name}</h2>
|
<h2 className="text-4xl font-bold text-white">{artistInfo.name}</h2>
|
||||||
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-white fill-blue-400 shrink-0"/>)}
|
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-white fill-blue-400 shrink-0"/>)}
|
||||||
</div>
|
</div>
|
||||||
{artistInfo.biography && (<p className="text-sm text-white/90">{artistInfo.biography}</p>)}
|
{artistInfo.biography && (<p className="text-sm text-white/90 line-clamp-4">{artistInfo.biography}</p>)}
|
||||||
<div className="flex items-center gap-2 text-sm flex-wrap text-white/90">
|
<div className="flex items-center gap-2 text-sm flex-wrap text-white/90">
|
||||||
{artistInfo.rank && (<>
|
{artistInfo.rank && (<>
|
||||||
<span>#{artistInfo.rank} rank</span>
|
<span>#{artistInfo.rank} rank</span>
|
||||||
@@ -370,7 +370,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
<h2 className="text-4xl font-bold">{artistInfo.name}</h2>
|
<h2 className="text-4xl font-bold">{artistInfo.name}</h2>
|
||||||
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-white fill-blue-500 shrink-0"/>)}
|
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-white fill-blue-500 shrink-0"/>)}
|
||||||
</div>
|
</div>
|
||||||
{artistInfo.biography && (<p className="text-sm text-muted-foreground">{artistInfo.biography}</p>)}
|
{artistInfo.biography && (<p className="text-sm text-muted-foreground line-clamp-4">{artistInfo.biography}</p>)}
|
||||||
<div className="flex items-center gap-2 text-sm flex-wrap">
|
<div className="flex items-center gap-2 text-sm flex-wrap">
|
||||||
{artistInfo.rank && (<>
|
{artistInfo.rank && (<>
|
||||||
<span>#{artistInfo.rank} rank</span>
|
<span>#{artistInfo.rank} rank</span>
|
||||||
@@ -446,14 +446,35 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
{activeTab === "albums" && albumList.length > 0 && (<div className="space-y-4">
|
{activeTab === "albums" && albumList.length > 0 && (<div className="space-y-4">
|
||||||
<h3 className="text-2xl font-bold">Discography</h3>
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<h3 className="text-2xl font-bold">Discography</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={onDownloadAll} size="sm" disabled={isDownloading}>
|
||||||
|
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||||
|
Download Discography
|
||||||
|
</Button>
|
||||||
|
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} size="sm" variant="secondary" disabled={isDownloading}>
|
||||||
|
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||||
|
Download Selected ({selectedTracks.length})
|
||||||
|
</Button>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
{albumList.map((album) => (<div key={album.id} className="group cursor-pointer" onClick={() => onAlbumClick({
|
{albumList.map((album) => {
|
||||||
id: album.id,
|
const albumTracks = trackList.filter(t => t.album_name === album.name);
|
||||||
name: album.name,
|
const tracksWithId = albumTracks.filter(t => t.spotify_id);
|
||||||
external_urls: album.external_urls,
|
const isSelected = tracksWithId.length > 0 && tracksWithId.every(t => selectedTracks.includes(t.spotify_id!));
|
||||||
})}>
|
const hasTracks = tracksWithId.length > 0;
|
||||||
|
return (<div key={album.id} className="group cursor-pointer relative" onClick={() => onAlbumClick({
|
||||||
|
id: album.id,
|
||||||
|
name: album.name,
|
||||||
|
external_urls: album.external_urls,
|
||||||
|
})}>
|
||||||
<div className="relative mb-2">
|
<div className="relative mb-2">
|
||||||
|
|
||||||
|
{hasTracks && (<div className={`absolute top-2 left-2 z-20 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Checkbox checked={isSelected} onCheckedChange={() => onToggleSelectAll(albumTracks)} className="bg-black/50 border-white/70 data-[state=checked]:bg-primary data-[state=checked]:border-primary"/>
|
||||||
|
</div>)}
|
||||||
{album.images && (<img src={album.images} alt={album.name} className="w-full aspect-square object-cover rounded-md shadow-md transition-shadow group-hover:shadow-xl"/>)}
|
{album.images && (<img src={album.images} alt={album.name} className="w-full aspect-square object-cover rounded-md shadow-md transition-shadow group-hover:shadow-xl"/>)}
|
||||||
<div className="absolute bottom-2 right-2">
|
<div className="absolute bottom-2 right-2">
|
||||||
<span className="text-[10px] uppercase font-bold px-1.5 py-0.5 rounded bg-black/60 text-white backdrop-blur-[2px]">
|
<span className="text-[10px] uppercase font-bold px-1.5 py-0.5 rounded bg-black/60 text-white backdrop-blur-[2px]">
|
||||||
@@ -469,7 +490,8 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
<span>{album.total_tracks} {album.total_tracks === 1 ? "track" : "tracks"}</span>
|
<span>{album.total_tracks} {album.total_tracks === 1 ? "track" : "tracks"}</span>
|
||||||
</>)}
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
</div>))}
|
</div>);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { ToggleGroup, ToggleGroupItem, } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem, } from "@/components/ui/toggle-group";
|
||||||
import { Upload, X, CheckCircle2, AlertCircle, Trash2, FileMusic, WandSparkles, } from "lucide-react";
|
import { Upload, X, CheckCircle2, AlertCircle, Trash2, FileMusic, WandSparkles, } from "lucide-react";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { ConvertAudio, SelectAudioFiles, } from "../../wailsjs/go/main/App";
|
import { ConvertAudio, SelectAudioFiles, SelectFolder, ListAudioFilesInDir, } from "../../wailsjs/go/main/App";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
||||||
interface AudioFile {
|
interface AudioFile {
|
||||||
@@ -152,6 +152,27 @@ export function AudioConverterPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const handleSelectFolder = async () => {
|
||||||
|
try {
|
||||||
|
const selectedFolder = await SelectFolder("");
|
||||||
|
if (selectedFolder) {
|
||||||
|
const folderFiles = await ListAudioFilesInDir(selectedFolder);
|
||||||
|
if (folderFiles && folderFiles.length > 0) {
|
||||||
|
addFiles(folderFiles.map((f) => f.path));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.info("No audio files found", {
|
||||||
|
description: "No FLAC or MP3 files found in the selected folder.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
toast.error("Folder Selection Failed", {
|
||||||
|
description: err instanceof Error ? err.message : "Failed to select folder",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
const addFiles = useCallback(async (paths: string[]) => {
|
const addFiles = useCallback(async (paths: string[]) => {
|
||||||
const validExtensions = [".mp3", ".flac"];
|
const validExtensions = [".mp3", ".flac"];
|
||||||
const m4aFiles = paths.filter((path) => {
|
const m4aFiles = paths.filter((path) => {
|
||||||
@@ -298,7 +319,11 @@ export function AudioConverterPage() {
|
|||||||
{files.length > 0 && (<div className="flex gap-2">
|
{files.length > 0 && (<div className="flex gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={handleSelectFiles}>
|
<Button variant="outline" size="sm" onClick={handleSelectFiles}>
|
||||||
<Upload className="h-4 w-4"/>
|
<Upload className="h-4 w-4"/>
|
||||||
Add More
|
Add Files
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleSelectFolder}>
|
||||||
|
<Upload className="h-4 w-4"/>
|
||||||
|
Add Folder
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={clearFiles} disabled={converting}>
|
<Button variant="outline" size="sm" onClick={clearFiles} disabled={converting}>
|
||||||
<Trash2 className="h-4 w-4"/>
|
<Trash2 className="h-4 w-4"/>
|
||||||
@@ -329,10 +354,16 @@ export function AudioConverterPage() {
|
|||||||
? "Drop your audio files here"
|
? "Drop your audio files here"
|
||||||
: "Drag and drop audio files here, or click the button below to select"}
|
: "Drag and drop audio files here, or click the button below to select"}
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={handleSelectFiles} size="lg">
|
<div className="flex gap-3">
|
||||||
<Upload className="h-5 w-5"/>
|
<Button onClick={handleSelectFiles} size="lg">
|
||||||
Select Files
|
<Upload className="h-5 w-5"/>
|
||||||
</Button>
|
Select Files
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSelectFolder} size="lg" variant="outline">
|
||||||
|
<Upload className="h-5 w-5"/>
|
||||||
|
Select Folder
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-4 text-center">
|
<p className="text-xs text-muted-foreground mt-4 text-center">
|
||||||
Supported formats: FLAC, MP3
|
Supported formats: FLAC, MP3
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import type { DragEvent } from "react";
|
|
||||||
import { UploadImageBytes, UploadImage, SelectImageVideo } from "../../wailsjs/go/main/App";
|
|
||||||
import { Upload, Loader2, ImagePlus, X, Check, FileVideo, ImageIcon } from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
interface UploadedFile {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
type: 'image' | 'video' | 'unknown';
|
|
||||||
status: 'uploading' | 'done' | 'error';
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
interface DragDropMediaProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
export function DragDropMedia({ value, onChange, className }: DragDropMediaProps) {
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const [files, setFiles] = useState<UploadedFile[]>(() => {
|
|
||||||
if (!value)
|
|
||||||
return [];
|
|
||||||
return value.split('\n').filter(line => line.trim()).map((line, i) => {
|
|
||||||
const match = line.match(/!\[(.*?)\]\((.*?)\)/);
|
|
||||||
if (match) {
|
|
||||||
return {
|
|
||||||
id: `init-${i}-${Date.now()}`,
|
|
||||||
name: match[1] === 'image' || match[1] === 'video' ? `file-${i}` : match[1],
|
|
||||||
url: match[2] || line,
|
|
||||||
type: (match[2] && match[2].match(/\.(mp4|mkv|webm|mov)$/i)) ? 'video' : 'image',
|
|
||||||
status: 'done'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: `init-${i}-${Date.now()}`,
|
|
||||||
name: 'unknown',
|
|
||||||
url: line,
|
|
||||||
type: 'image',
|
|
||||||
status: 'done'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
|
||||||
const newValue = files
|
|
||||||
.filter(f => f.status === 'done' && f.url)
|
|
||||||
.map(f => f.url)
|
|
||||||
.join('\n');
|
|
||||||
if (newValue !== value) {
|
|
||||||
onChange(newValue);
|
|
||||||
}
|
|
||||||
}, [files]);
|
|
||||||
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsDragging(true);
|
|
||||||
};
|
|
||||||
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsDragging(false);
|
|
||||||
};
|
|
||||||
const handleDrop = async (e: DragEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsDragging(false);
|
|
||||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
|
||||||
await handleFiles(Array.from(e.dataTransfer.files));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleFiles = async (fileList: File[]) => {
|
|
||||||
const timestamp = Date.now();
|
|
||||||
const newFiles: UploadedFile[] = fileList.map((f, i) => ({
|
|
||||||
id: `drop-${timestamp}-${i}`,
|
|
||||||
name: f.name,
|
|
||||||
url: '',
|
|
||||||
type: f.type.startsWith('video') ? 'video' : 'image',
|
|
||||||
status: 'uploading'
|
|
||||||
}));
|
|
||||||
setFiles(prev => [...prev, ...newFiles]);
|
|
||||||
for (let i = 0; i < fileList.length; i++) {
|
|
||||||
const file = fileList[i];
|
|
||||||
const fileId = newFiles[i].id;
|
|
||||||
try {
|
|
||||||
const base64 = await fileToBase64(file);
|
|
||||||
const result = await UploadImageBytes(file.name, base64);
|
|
||||||
setFiles(prev => prev.map(f => f.id === fileId
|
|
||||||
? { ...f, status: 'done', url: result }
|
|
||||||
: f));
|
|
||||||
}
|
|
||||||
catch (err: any) {
|
|
||||||
console.error("Upload failed", err);
|
|
||||||
setFiles(prev => prev.map(f => f.id === fileId
|
|
||||||
? { ...f, status: 'error', error: err.message || "Upload failed" }
|
|
||||||
: f));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleSelectFile = async () => {
|
|
||||||
try {
|
|
||||||
const paths = await SelectImageVideo();
|
|
||||||
if (paths && paths.length > 0) {
|
|
||||||
const timestamp = Date.now();
|
|
||||||
const newFiles: UploadedFile[] = paths.map((p, i) => ({
|
|
||||||
id: `select-${timestamp}-${i}`,
|
|
||||||
name: p.split(/[\\/]/).pop() || 'unknown',
|
|
||||||
url: '',
|
|
||||||
type: p.match(/\.(mp4|mkv|webm|mov)$/i) ? 'video' : 'image',
|
|
||||||
status: 'uploading'
|
|
||||||
}));
|
|
||||||
setFiles(prev => [...prev, ...newFiles]);
|
|
||||||
for (let i = 0; i < paths.length; i++) {
|
|
||||||
const path = paths[i];
|
|
||||||
const fileId = newFiles[i].id;
|
|
||||||
try {
|
|
||||||
const result = await UploadImage(path);
|
|
||||||
setFiles(prev => prev.map(f => f.id === fileId
|
|
||||||
? { ...f, status: 'done', url: result }
|
|
||||||
: f));
|
|
||||||
}
|
|
||||||
catch (err: any) {
|
|
||||||
setFiles(prev => prev.map(f => f.id === fileId
|
|
||||||
? { ...f, status: 'error', error: err.message }
|
|
||||||
: f));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (err: any) {
|
|
||||||
console.error("Select file failed", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const removeFile = (index: number) => {
|
|
||||||
setFiles(prev => prev.filter((_, i) => i !== index));
|
|
||||||
};
|
|
||||||
return (<div className={cn("relative group flex flex-col gap-2 p-4 border-2 border-dashed rounded-lg transition-colors border-muted-foreground/25 hover:border-primary/50 min-h-[14rem]", isDragging ? "border-primary bg-primary/10" : "bg-muted/5", className)} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} onClick={(e) => {
|
|
||||||
if (e.target === e.currentTarget)
|
|
||||||
handleSelectFile();
|
|
||||||
}}>
|
|
||||||
{files.length === 0 && (<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none opacity-50">
|
|
||||||
<ImagePlus className="h-10 w-10 mb-2"/>
|
|
||||||
<span className="text-sm font-medium">Drop media here or click to browse</span>
|
|
||||||
<span className="text-xs text-muted-foreground mt-1">Supports PNG, JPG, MP4, MOV</span>
|
|
||||||
</div>)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 z-10 w-full">
|
|
||||||
{files.map((file, i) => (<div key={i} className="flex items-center gap-3 p-2 rounded-md bg-background/80 backdrop-blur-sm border shadow-sm animate-in fade-in slide-in-from-bottom-2">
|
|
||||||
{file.type === 'video' ? <FileVideo className="h-8 w-8 text-primary"/> : <ImageIcon className="h-8 w-8 text-primary"/>}
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-sm font-medium truncate">{file.name}</div>
|
|
||||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
|
||||||
{file.status === 'uploading' && <span className="text-yellow-500 flex items-center"><Loader2 className="h-3 w-3 animate-spin mr-1"/> Uploading...</span>}
|
|
||||||
{file.status === 'done' && <span className="text-green-500 flex items-center"><Check className="h-3 w-3 mr-1"/> Ready</span>}
|
|
||||||
{file.status === 'error' && <span className="text-red-500">{file.error || 'Failed'}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive" onClick={(e) => { e.stopPropagation(); removeFile(i); }}>
|
|
||||||
<X className="h-4 w-4"/>
|
|
||||||
</Button>
|
|
||||||
</div>))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{isDragging && (<div className="absolute inset-0 flex items-center justify-center bg-background/50 rounded-lg pointer-events-none z-20">
|
|
||||||
<div className="flex flex-col items-center text-primary font-medium">
|
|
||||||
<Upload className="h-10 w-10 mb-2 animate-bounce"/>
|
|
||||||
<span>Drop files to add</span>
|
|
||||||
</div>
|
|
||||||
</div>)}
|
|
||||||
</div>);
|
|
||||||
}
|
|
||||||
const fileToBase64 = (file: File): Promise<string> => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
reader.onload = () => resolve(reader.result as string);
|
|
||||||
reader.onerror = (error) => reject(error);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -549,7 +549,7 @@ export function FileManagerPage() {
|
|||||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
<p className="text-xs whitespace-nowrap">Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}</p>
|
<p className="text-xs whitespace-nowrap">Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}, {"{date}"}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -571,7 +571,7 @@ export function FileManagerPage() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Preview: <span className="font-mono">{renameFormat.replace(/\{title\}/g, "All The Stars").replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span>
|
Preview: <span className="font-mono">{renameFormat.replace(/\{title\}/g, "All The Stars").replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018").replace(/\{date\}/g, "2018-02-09")}.flac</span>
|
||||||
</p>
|
</p>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
<td className="p-3 align-middle text-left hidden lg:table-cell">
|
<td className="p-3 align-middle text-left hidden lg:table-cell">
|
||||||
<div className="flex flex-col items-start gap-1">
|
<div className="flex flex-col items-start gap-1">
|
||||||
<span className="text-xs font-bold text-foreground">
|
<span className="text-xs font-bold text-foreground">
|
||||||
{['HI_RES_LOSSLESS', 'LOSSLESS'].includes(item.format) ? 'FLAC' : item.format}
|
{['HI_RES_LOSSLESS', 'LOSSLESS', 'flac', '6', '7', '27'].includes(item.format?.toLowerCase() || '') ? 'FLAC' : item.format?.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
{item.quality && <span className="text-[11px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>}
|
{item.quality && <span className="text-[11px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||||||
import { SearchAndSort } from "./SearchAndSort";
|
import { SearchAndSort } from "./SearchAndSort";
|
||||||
import { TrackList } from "./TrackList";
|
import { TrackList } from "./TrackList";
|
||||||
import { DownloadProgress } from "./DownloadProgress";
|
import { DownloadProgress } from "./DownloadProgress";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
|
import { downloadCover } from "@/lib/api";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||||
|
import { parseTemplate, type TemplateData } from "@/lib/settings";
|
||||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||||
interface PlaylistInfoProps {
|
interface PlaylistInfoProps {
|
||||||
playlistInfo: {
|
playlistInfo: {
|
||||||
@@ -81,6 +87,66 @@ interface PlaylistInfoProps {
|
|||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, onBack, }: PlaylistInfoProps) {
|
export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, onBack, }: PlaylistInfoProps) {
|
||||||
|
const settings = getSettings();
|
||||||
|
const [downloadingPlaylistCover, setDownloadingPlaylistCover] = useState(false);
|
||||||
|
const handleDownloadPlaylistCover = async () => {
|
||||||
|
if (!playlistInfo.cover)
|
||||||
|
return;
|
||||||
|
setDownloadingPlaylistCover(true);
|
||||||
|
try {
|
||||||
|
const os = settings.operatingSystem;
|
||||||
|
let outputDir = settings.downloadPath;
|
||||||
|
const playlistName = playlistInfo.owner.name;
|
||||||
|
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||||
|
const templateData: TemplateData = {
|
||||||
|
artist: "",
|
||||||
|
album: "",
|
||||||
|
album_artist: "",
|
||||||
|
title: playlistName.replace(/\//g, placeholder),
|
||||||
|
playlist: playlistName.replace(/\//g, placeholder),
|
||||||
|
};
|
||||||
|
if (settings.createPlaylistFolder && playlistName) {
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||||
|
}
|
||||||
|
if (settings.folderTemplate) {
|
||||||
|
const folderPath = parseTemplate(settings.folderTemplate, templateData);
|
||||||
|
if (folderPath) {
|
||||||
|
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
||||||
|
for (const part of parts) {
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(part.replace(new RegExp(placeholder, "g"), " "), os));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const response = await downloadCover({
|
||||||
|
cover_url: playlistInfo.cover,
|
||||||
|
track_name: playlistName,
|
||||||
|
artist_name: "",
|
||||||
|
album_name: "",
|
||||||
|
album_artist: "",
|
||||||
|
release_date: "",
|
||||||
|
output_dir: outputDir,
|
||||||
|
filename_format: "title",
|
||||||
|
track_number: false,
|
||||||
|
position: 0,
|
||||||
|
disc_number: 0,
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
if (response.already_exists)
|
||||||
|
toast.info("Cover already exists");
|
||||||
|
else
|
||||||
|
toast.success("Playlist cover downloaded");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.error(response.error || "Failed to download cover");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to download cover");
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setDownloadingPlaylistCover(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
return (<div className="space-y-6">
|
return (<div className="space-y-6">
|
||||||
<Card className="relative">
|
<Card className="relative">
|
||||||
{onBack && (<div className="absolute top-4 right-4 z-10">
|
{onBack && (<div className="absolute top-4 right-4 z-10">
|
||||||
@@ -90,7 +156,19 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
|
|||||||
</div>)}
|
</div>)}
|
||||||
<CardContent className="px-6">
|
<CardContent className="px-6">
|
||||||
<div className="flex gap-6 items-start">
|
<div className="flex gap-6 items-start">
|
||||||
{playlistInfo.cover && (<img src={playlistInfo.cover} alt={playlistInfo.owner.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)}
|
{playlistInfo.cover && (<div className="relative group shrink-0 w-48 h-48">
|
||||||
|
<img src={playlistInfo.cover} alt={playlistInfo.owner.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity rounded-md">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="secondary" size="icon" className="h-9 w-9 shadow-lg" onClick={handleDownloadPlaylistCover} disabled={downloadingPlaylistCover}>
|
||||||
|
{downloadingPlaylistCover ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent><p>Download Playlist Cover</p></TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
<div className="flex-1 space-y-4">
|
<div className="flex-1 space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-medium">Playlist</p>
|
<p className="text-sm font-medium">Playlist</p>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function SearchAndSort({ searchQuery, sortBy, onSearchChange, onSortChang
|
|||||||
<SelectItem value="plays-desc">Plays (High)</SelectItem>
|
<SelectItem value="plays-desc">Plays (High)</SelectItem>
|
||||||
<SelectItem value="downloaded">Downloaded</SelectItem>
|
<SelectItem value="downloaded">Downloaded</SelectItem>
|
||||||
<SelectItem value="not-downloaded">Not Downloaded</SelectItem>
|
<SelectItem value="not-downloaded">Not Downloaded</SelectItem>
|
||||||
|
<SelectItem value="failed">Failed Downloads</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>);
|
</div>);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { InputWithContext } from "@/components/ui/input-with-context";
|
import { InputWithContext } from "@/components/ui/input-with-context";
|
||||||
import { CloudDownload, XCircle, Link, Search, X, ChevronDown, } from "lucide-react";
|
import { CloudDownload, XCircle, Link, Search, X, ChevronDown, ArrowUpDown, } from "lucide-react";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||||
import { FetchHistory } from "@/components/FetchHistory";
|
import { FetchHistory } from "@/components/FetchHistory";
|
||||||
@@ -244,6 +245,13 @@ interface SearchBarProps {
|
|||||||
export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, region, onRegionChange, }: SearchBarProps) {
|
export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, region, onRegionChange, }: SearchBarProps) {
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [searchResults, setSearchResults] = useState<backend.SearchResponse | null>(null);
|
const [searchResults, setSearchResults] = useState<backend.SearchResponse | null>(null);
|
||||||
|
const [resultFilter, setResultFilter] = useState("");
|
||||||
|
const [sortOrders, setSortOrders] = useState<Record<ResultTab, string>>({
|
||||||
|
tracks: "default",
|
||||||
|
albums: "default",
|
||||||
|
artists: "default",
|
||||||
|
playlists: "default",
|
||||||
|
});
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
const [lastSearchedQuery, setLastSearchedQuery] = useState("");
|
const [lastSearchedQuery, setLastSearchedQuery] = useState("");
|
||||||
@@ -317,6 +325,7 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
|||||||
limit: SEARCH_LIMIT,
|
limit: SEARCH_LIMIT,
|
||||||
});
|
});
|
||||||
setSearchResults(results);
|
setSearchResults(results);
|
||||||
|
setResultFilter("");
|
||||||
setLastSearchedQuery(searchQuery.trim());
|
setLastSearchedQuery(searchQuery.trim());
|
||||||
saveRecentSearch(searchQuery.trim());
|
saveRecentSearch(searchQuery.trim());
|
||||||
setHasMore({
|
setHasMore({
|
||||||
@@ -456,6 +465,88 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
|||||||
return searchResults.playlists.length;
|
return searchResults.playlists.length;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const sortedResults = useMemo(() => {
|
||||||
|
if (!searchResults)
|
||||||
|
return { tracks: [], albums: [], artists: [], playlists: [] };
|
||||||
|
const filterStr = resultFilter.toLowerCase();
|
||||||
|
let tracks = [...searchResults.tracks];
|
||||||
|
if (filterStr) {
|
||||||
|
tracks = tracks.filter(t => (t.name || '').toLowerCase().includes(filterStr) || (t.artists || '').toLowerCase().includes(filterStr));
|
||||||
|
}
|
||||||
|
const tSort = sortOrders.tracks;
|
||||||
|
if (tSort !== 'default') {
|
||||||
|
tracks.sort((a, b) => {
|
||||||
|
if (tSort === 'title-asc')
|
||||||
|
return (a.name || '').localeCompare(b.name || '');
|
||||||
|
if (tSort === 'title-desc')
|
||||||
|
return (b.name || '').localeCompare(a.name || '');
|
||||||
|
if (tSort === 'artist-asc')
|
||||||
|
return (a.artists || '').localeCompare(b.artists || '');
|
||||||
|
if (tSort === 'artist-desc')
|
||||||
|
return (b.artists || '').localeCompare(a.artists || '');
|
||||||
|
if (tSort === 'duration-desc')
|
||||||
|
return (b.duration_ms || 0) - (a.duration_ms || 0);
|
||||||
|
if (tSort === 'duration-asc')
|
||||||
|
return (a.duration_ms || 0) - (b.duration_ms || 0);
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let albums = [...searchResults.albums];
|
||||||
|
if (filterStr) {
|
||||||
|
albums = albums.filter(a => (a.name || '').toLowerCase().includes(filterStr) || (a.artists || '').toLowerCase().includes(filterStr));
|
||||||
|
}
|
||||||
|
const alSort = sortOrders.albums;
|
||||||
|
if (alSort !== 'default') {
|
||||||
|
albums.sort((a, b) => {
|
||||||
|
if (alSort === 'title-asc')
|
||||||
|
return (a.name || '').localeCompare(b.name || '');
|
||||||
|
if (alSort === 'title-desc')
|
||||||
|
return (b.name || '').localeCompare(a.name || '');
|
||||||
|
if (alSort === 'artist-asc')
|
||||||
|
return (a.artists || '').localeCompare(b.artists || '');
|
||||||
|
if (alSort === 'artist-desc')
|
||||||
|
return (b.artists || '').localeCompare(a.artists || '');
|
||||||
|
if (alSort === 'year-desc')
|
||||||
|
return (b.release_date || '').localeCompare(a.release_date || '');
|
||||||
|
if (alSort === 'year-asc')
|
||||||
|
return (a.release_date || '').localeCompare(b.release_date || '');
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let artists = [...searchResults.artists];
|
||||||
|
if (filterStr) {
|
||||||
|
artists = artists.filter(a => (a.name || '').toLowerCase().includes(filterStr));
|
||||||
|
}
|
||||||
|
const arSort = sortOrders.artists;
|
||||||
|
if (arSort !== 'default') {
|
||||||
|
artists.sort((a, b) => {
|
||||||
|
if (arSort === 'name-asc')
|
||||||
|
return (a.name || '').localeCompare(b.name || '');
|
||||||
|
if (arSort === 'name-desc')
|
||||||
|
return (b.name || '').localeCompare(a.name || '');
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let playlists = [...searchResults.playlists];
|
||||||
|
if (filterStr) {
|
||||||
|
playlists = playlists.filter(p => (p.name || '').toLowerCase().includes(filterStr) || (p.owner || '').toLowerCase().includes(filterStr));
|
||||||
|
}
|
||||||
|
const pSort = sortOrders.playlists;
|
||||||
|
if (pSort !== 'default') {
|
||||||
|
playlists.sort((a, b) => {
|
||||||
|
if (pSort === 'title-asc')
|
||||||
|
return (a.name || '').localeCompare(b.name || '');
|
||||||
|
if (pSort === 'title-desc')
|
||||||
|
return (b.name || '').localeCompare(a.name || '');
|
||||||
|
if (pSort === 'owner-asc')
|
||||||
|
return (a.owner || '').localeCompare(b.owner || '');
|
||||||
|
if (pSort === 'owner-desc')
|
||||||
|
return (b.owner || '').localeCompare(a.owner || '');
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { tracks, albums, artists, playlists };
|
||||||
|
}, [searchResults, sortOrders, resultFilter]);
|
||||||
const tabs: {
|
const tabs: {
|
||||||
key: ResultTab;
|
key: ResultTab;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -490,6 +581,7 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
|||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
setSearchResults(null);
|
setSearchResults(null);
|
||||||
setLastSearchedQuery("");
|
setLastSearchedQuery("");
|
||||||
|
setResultFilter("");
|
||||||
}}>
|
}}>
|
||||||
<XCircle className="h-4 w-4"/>
|
<XCircle className="h-4 w-4"/>
|
||||||
</button>)}
|
</button>)}
|
||||||
@@ -550,7 +642,7 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
|||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
{!isSearching && hasAnyResults && (<>
|
{!isSearching && hasAnyResults && (<>
|
||||||
<div className="flex gap-1 border-b">
|
<div className="flex gap-1 border-b mb-4">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const count = getTabCount(tab.key);
|
const count = getTabCount(tab.key);
|
||||||
if (count === 0)
|
if (count === 0)
|
||||||
@@ -563,9 +655,54 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
|
||||||
|
<Input placeholder={`Search ${activeTab}...`} value={resultFilter} onChange={(e) => setResultFilter(e.target.value)} className="pl-10 pr-8"/>
|
||||||
|
{resultFilter && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => setResultFilter("")}>
|
||||||
|
<XCircle className="h-4 w-4"/>
|
||||||
|
</button>)}
|
||||||
|
</div>
|
||||||
|
<Select value={sortOrders[activeTab]} onValueChange={(val) => setSortOrders(prev => ({ ...prev, [activeTab]: val }))}>
|
||||||
|
<SelectTrigger className="w-[170px] bg-background gap-1.5">
|
||||||
|
<ArrowUpDown className="h-4 w-4 text-muted-foreground"/>
|
||||||
|
<SelectValue placeholder="Sort by"/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="default">Default</SelectItem>
|
||||||
|
{activeTab === 'tracks' && (<>
|
||||||
|
<SelectItem value="title-asc">Title (A-Z)</SelectItem>
|
||||||
|
<SelectItem value="title-desc">Title (Z-A)</SelectItem>
|
||||||
|
<SelectItem value="artist-asc">Artist (A-Z)</SelectItem>
|
||||||
|
<SelectItem value="artist-desc">Artist (Z-A)</SelectItem>
|
||||||
|
<SelectItem value="duration-desc">Duration (Longest)</SelectItem>
|
||||||
|
<SelectItem value="duration-asc">Duration (Shortest)</SelectItem>
|
||||||
|
</>)}
|
||||||
|
{activeTab === 'albums' && (<>
|
||||||
|
<SelectItem value="title-asc">Title (A-Z)</SelectItem>
|
||||||
|
<SelectItem value="title-desc">Title (Z-A)</SelectItem>
|
||||||
|
<SelectItem value="artist-asc">Artist (A-Z)</SelectItem>
|
||||||
|
<SelectItem value="artist-desc">Artist (Z-A)</SelectItem>
|
||||||
|
<SelectItem value="year-desc">Year (Newest)</SelectItem>
|
||||||
|
<SelectItem value="year-asc">Year (Oldest)</SelectItem>
|
||||||
|
</>)}
|
||||||
|
{activeTab === 'artists' && (<>
|
||||||
|
<SelectItem value="name-asc">Name (A-Z)</SelectItem>
|
||||||
|
<SelectItem value="name-desc">Name (Z-A)</SelectItem>
|
||||||
|
</>)}
|
||||||
|
{activeTab === 'playlists' && (<>
|
||||||
|
<SelectItem value="title-asc">Title (A-Z)</SelectItem>
|
||||||
|
<SelectItem value="title-desc">Title (Z-A)</SelectItem>
|
||||||
|
<SelectItem value="owner-asc">Owner (A-Z)</SelectItem>
|
||||||
|
<SelectItem value="owner-desc">Owner (Z-A)</SelectItem>
|
||||||
|
</>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
{activeTab === "tracks" &&
|
{activeTab === "tracks" &&
|
||||||
searchResults?.tracks.map((track) => (<button key={track.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(track.external_urls)}>
|
sortedResults.tracks.map((track) => (<button key={track.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(track.external_urls)}>
|
||||||
{track.images ? (<img src={track.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
{track.images ? (<img src={track.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
@@ -584,7 +721,7 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
|||||||
</button>))}
|
</button>))}
|
||||||
|
|
||||||
{activeTab === "albums" &&
|
{activeTab === "albums" &&
|
||||||
searchResults?.albums.map((album) => (<button key={album.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(album.external_urls)}>
|
sortedResults.albums.map((album) => (<button key={album.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(album.external_urls)}>
|
||||||
{album.images ? (<img src={album.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
{album.images ? (<img src={album.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium truncate">{album.name}</p>
|
<p className="font-medium truncate">{album.name}</p>
|
||||||
@@ -598,7 +735,7 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
|||||||
</button>))}
|
</button>))}
|
||||||
|
|
||||||
{activeTab === "artists" &&
|
{activeTab === "artists" &&
|
||||||
searchResults?.artists.map((artist) => (<button key={artist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(artist.external_urls)}>
|
sortedResults.artists.map((artist) => (<button key={artist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(artist.external_urls)}>
|
||||||
{artist.images ? (<img src={artist.images} alt="" className="w-12 h-12 rounded-full object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded-full bg-muted shrink-0"/>)}
|
{artist.images ? (<img src={artist.images} alt="" className="w-12 h-12 rounded-full object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded-full bg-muted shrink-0"/>)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium truncate">{artist.name}</p>
|
<p className="font-medium truncate">{artist.name}</p>
|
||||||
@@ -607,7 +744,7 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
|||||||
</button>))}
|
</button>))}
|
||||||
|
|
||||||
{activeTab === "playlists" &&
|
{activeTab === "playlists" &&
|
||||||
searchResults?.playlists.map((playlist) => (<button key={playlist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(playlist.external_urls)}>
|
sortedResults.playlists.map((playlist) => (<button key={playlist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(playlist.external_urls)}>
|
||||||
{playlist.images ? (<img src={playlist.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
{playlist.images ? (<img src={playlist.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium truncate">{playlist.name}</p>
|
<p className="font-medium truncate">{playlist.name}</p>
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import { InputWithContext } from "@/components/ui/input-with-context";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||||
import { FolderOpen, Save, RotateCcw, Info, ArrowRight, Settings, FolderCog, } from "lucide-react";
|
import { FolderOpen, Save, RotateCcw, Info, ArrowRight, MonitorCog, FolderCog, Router, FolderLock } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset, } from "@/lib/settings";
|
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset, } from "@/lib/settings";
|
||||||
import { themes, applyTheme } from "@/lib/themes";
|
import { themes, applyTheme } from "@/lib/themes";
|
||||||
import { SelectFolder } from "../../wailsjs/go/main/App";
|
import { SelectFolder, OpenConfigFolder } from "../../wailsjs/go/main/App";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
import { ApiStatusTab } from "./ApiStatusTab";
|
||||||
const TidalIcon = ({ className }: {
|
const TidalIcon = ({ className }: {
|
||||||
className?: string;
|
className?: string;
|
||||||
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
|
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
|
||||||
@@ -118,17 +119,26 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => {
|
const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => {
|
||||||
setTempSettings((prev) => ({ ...prev, tidalQuality: value }));
|
setTempSettings((prev) => ({ ...prev, tidalQuality: value }));
|
||||||
};
|
};
|
||||||
const handleQobuzQualityChange = (value: "6" | "7") => {
|
const handleQobuzQualityChange = (value: "6" | "7" | "27") => {
|
||||||
setTempSettings((prev) => ({ ...prev, qobuzQuality: value }));
|
setTempSettings((prev) => ({ ...prev, qobuzQuality: value }));
|
||||||
};
|
};
|
||||||
const handleAutoQualityChange = async (value: "16" | "24") => {
|
const handleAutoQualityChange = async (value: "16" | "24") => {
|
||||||
setTempSettings((prev) => ({ ...prev, autoQuality: value }));
|
setTempSettings((prev) => ({ ...prev, autoQuality: value }));
|
||||||
};
|
};
|
||||||
const [activeTab, setActiveTab] = useState<"general" | "files">("general");
|
const [activeTab, setActiveTab] = useState<"general" | "files" | "api">("general");
|
||||||
return (<div className="space-y-4 h-full flex flex-col">
|
return (<div className="space-y-4 h-full flex flex-col">
|
||||||
<div className="flex items-center justify-between shrink-0">
|
<div className="flex items-center justify-between shrink-0">
|
||||||
<h1 className="text-2xl font-bold">Settings</h1>
|
<h1 className="text-2xl font-bold">Settings</h1>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={async () => { try {
|
||||||
|
await OpenConfigFolder();
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
toast.error(`Failed to open config folder: ${e}`);
|
||||||
|
} }} className="gap-1.5">
|
||||||
|
<FolderLock className="h-4 w-4"/>
|
||||||
|
Open Config Folder
|
||||||
|
</Button>
|
||||||
<Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
|
<Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
|
||||||
<RotateCcw className="h-4 w-4"/>
|
<RotateCcw className="h-4 w-4"/>
|
||||||
Reset to Default
|
Reset to Default
|
||||||
@@ -142,13 +152,17 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
|
|
||||||
<div className="flex gap-2 border-b shrink-0">
|
<div className="flex gap-2 border-b shrink-0">
|
||||||
<Button variant={activeTab === "general" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("general")} className="rounded-b-none gap-2">
|
<Button variant={activeTab === "general" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("general")} className="rounded-b-none gap-2">
|
||||||
<Settings className="h-4 w-4"/>
|
<MonitorCog className="h-4 w-4"/>
|
||||||
General
|
General
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant={activeTab === "files" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("files")} className="rounded-b-none gap-2">
|
<Button variant={activeTab === "files" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("files")} className="rounded-b-none gap-2">
|
||||||
<FolderCog className="h-4 w-4"/>
|
<FolderCog className="h-4 w-4"/>
|
||||||
File Management
|
File Management
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant={activeTab === "api" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("api")} className="rounded-b-none gap-2">
|
||||||
|
<Router className="h-4 w-4"/>
|
||||||
|
API Status
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto pt-4">
|
<div className="flex-1 overflow-y-auto pt-4">
|
||||||
@@ -234,7 +248,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="downloader">Source</Label>
|
<Label htmlFor="downloader">Source</Label>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<Select value={tempSettings.downloader} onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({
|
<Select value={tempSettings.downloader} onValueChange={(value: any) => setTempSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
downloader: value,
|
downloader: value,
|
||||||
}))}>
|
}))}>
|
||||||
@@ -261,6 +275,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
Amazon Music
|
Amazon Music
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
@@ -273,50 +288,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="tidal-qobuz">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<TidalIcon className="fill-current"/>
|
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
|
||||||
<QobuzIcon className="fill-current"/>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="tidal-amazon">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<TidalIcon className="fill-current"/>
|
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
|
||||||
<AmazonIcon className="fill-current"/>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="qobuz-tidal">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<QobuzIcon className="fill-current"/>
|
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
|
||||||
<TidalIcon className="fill-current"/>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="qobuz-amazon">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<QobuzIcon className="fill-current"/>
|
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
|
||||||
<AmazonIcon className="fill-current"/>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="amazon-tidal">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<AmazonIcon className="fill-current"/>
|
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
|
||||||
<TidalIcon className="fill-current"/>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="amazon-qobuz">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<AmazonIcon className="fill-current"/>
|
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
|
||||||
<QobuzIcon className="fill-current"/>
|
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
|
||||||
<TidalIcon className="fill-current"/>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="tidal-qobuz-amazon">
|
<SelectItem value="tidal-qobuz-amazon">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<TidalIcon className="fill-current"/>
|
<TidalIcon className="fill-current"/>
|
||||||
@@ -371,6 +343,50 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
<TidalIcon className="fill-current"/>
|
<TidalIcon className="fill-current"/>
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
||||||
|
|
||||||
|
<SelectItem value="tidal-qobuz">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<TidalIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<QobuzIcon className="fill-current"/>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="tidal-amazon">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<TidalIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<AmazonIcon className="fill-current"/>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="qobuz-tidal">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<QobuzIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<TidalIcon className="fill-current"/>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="qobuz-amazon">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<QobuzIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<AmazonIcon className="fill-current"/>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="amazon-tidal">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<AmazonIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<TidalIcon className="fill-current"/>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="amazon-qobuz">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<AmazonIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<QobuzIcon className="fill-current"/>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
@@ -403,19 +419,20 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="6">16-bit/44.1kHz</SelectItem>
|
<SelectItem value="6">16-bit/44.1kHz</SelectItem>
|
||||||
<SelectItem value="7">24-bit/48kHz</SelectItem>
|
<SelectItem value="27">24-bit/48kHz - 192kHz</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>)}
|
</Select>)}
|
||||||
|
|
||||||
{tempSettings.downloader === "amazon" && (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default">
|
{tempSettings.downloader === "amazon" && (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default">
|
||||||
16-bit - 24-bit/44.1kHz - 192kHz
|
16-bit - 24-bit/44.1kHz - 192kHz
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{((tempSettings.downloader === "tidal" &&
|
{((tempSettings.downloader === "tidal" &&
|
||||||
tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
|
tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
|
||||||
(tempSettings.downloader === "qobuz" &&
|
(tempSettings.downloader === "qobuz" &&
|
||||||
tempSettings.qobuzQuality === "7") ||
|
tempSettings.qobuzQuality === "27") ||
|
||||||
(tempSettings.downloader === "auto" &&
|
(tempSettings.downloader === "auto" &&
|
||||||
tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pt-2">
|
tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pt-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -451,6 +468,24 @@ 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="embed-genre" checked={tempSettings.embedGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
embedGenre: checked,
|
||||||
|
}))}/>
|
||||||
|
<Label htmlFor="embed-genre" className="cursor-pointer text-sm font-normal">
|
||||||
|
Embed Genre
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{tempSettings.embedGenre && (<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>)}
|
||||||
@@ -501,10 +536,14 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
Preview:{" "}
|
Preview:{" "}
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{tempSettings.folderTemplate
|
{tempSettings.folderTemplate
|
||||||
.replace(/\{artist\}/g, "Kendrick Lamar, SZA")
|
.replace(/\{artist\}/g, tempSettings.separator === "comma" ? "Kendrick Lamar, SZA" : "Kendrick Lamar; SZA")
|
||||||
.replace(/\{album\}/g, "Black Panther")
|
.replace(/\{album\}/g, "Black Panther")
|
||||||
.replace(/\{album_artist\}/g, "Kendrick Lamar")
|
.replace(/\{album_artist\}/g, "Kendrick Lamar")
|
||||||
.replace(/\{year\}/g, "2018")}
|
.replace(/\{title\}/g, "All The Stars")
|
||||||
|
.replace(/\{track\}/g, "01")
|
||||||
|
.replace(/\{disc\}/g, "1")
|
||||||
|
.replace(/\{year\}/g, "2018")
|
||||||
|
.replace(/\{date\}/g, "2018-02-09")}
|
||||||
/
|
/
|
||||||
</span>
|
</span>
|
||||||
</p>)}
|
</p>)}
|
||||||
@@ -539,6 +578,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">
|
||||||
@@ -581,21 +622,44 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
filenameTemplate: e.target.value,
|
filenameTemplate: e.target.value,
|
||||||
}))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)}
|
}))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
<Label className="text-sm">Separator</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select value={tempSettings.separator} onValueChange={(value: "comma" | "semicolon") => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
separator: value,
|
||||||
|
}))}>
|
||||||
|
<SelectTrigger className="h-9 w-fit">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="comma">Comma (,)</SelectItem>
|
||||||
|
<SelectItem value="semicolon">Semicolon (;)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
|
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
|
||||||
Preview:{" "}
|
Preview:{" "}
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{tempSettings.filenameTemplate
|
{tempSettings.filenameTemplate
|
||||||
.replace(/\{artist\}/g, "Kendrick Lamar, SZA")
|
.replace(/\{artist\}/g, tempSettings.separator === "comma" ? "Kendrick Lamar, SZA" : "Kendrick Lamar; SZA")
|
||||||
.replace(/\{album_artist\}/g, "Kendrick Lamar")
|
.replace(/\{album_artist\}/g, "Kendrick Lamar")
|
||||||
|
.replace(/\{album\}/g, "Black Panther")
|
||||||
.replace(/\{title\}/g, "All The Stars")
|
.replace(/\{title\}/g, "All The Stars")
|
||||||
.replace(/\{track\}/g, "01")
|
.replace(/\{track\}/g, "01")
|
||||||
.replace(/\{disc\}/g, "1")
|
.replace(/\{disc\}/g, "1")
|
||||||
.replace(/\{year\}/g, "2018")}
|
.replace(/\{year\}/g, "2018")
|
||||||
|
.replace(/\{date\}/g, "2018-02-09")}
|
||||||
.flac
|
.flac
|
||||||
</span>
|
</span>
|
||||||
</p>)}
|
</p>)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
|
{activeTab === "api" && (<ApiStatusTab />)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
||||||
|
|||||||
+106
-110
@@ -7,12 +7,12 @@ import { FileMusicIcon } from "@/components/ui/file-music";
|
|||||||
import { FilePenIcon } from "@/components/ui/file-pen";
|
import { FilePenIcon } from "@/components/ui/file-pen";
|
||||||
import { CoffeeIcon } from "@/components/ui/coffee";
|
import { CoffeeIcon } from "@/components/ui/coffee";
|
||||||
import { BadgeAlertIcon } from "@/components/ui/badge-alert";
|
import { BadgeAlertIcon } from "@/components/ui/badge-alert";
|
||||||
|
import { GithubIcon } from "@/components/ui/github";
|
||||||
|
import { BlocksIcon } from "@/components/ui/blocks-icon";
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { openExternal } from "@/lib/utils";
|
import { openExternal } from "@/lib/utils";
|
||||||
import BmcLogo from "@/assets/bmc-logo-side.svg";
|
|
||||||
import BmcLogoWhite from "@/assets/bmc-logo-side-white.svg";
|
|
||||||
import KofiLogo from "@/assets/kofi_symbol.svg";
|
|
||||||
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager" | "about" | "history";
|
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager" | "about" | "history";
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
currentPage: PageType;
|
currentPage: PageType;
|
||||||
@@ -20,118 +20,114 @@ interface SidebarProps {
|
|||||||
}
|
}
|
||||||
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||||
return (<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
|
return (<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
|
||||||
<div className="flex flex-col gap-2 flex-1">
|
<div className="flex flex-col gap-2 flex-1">
|
||||||
|
<Tooltip delayDuration={0}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant={currentPage === "main" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "main" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("main")}>
|
||||||
|
<HomeIcon size={20}/>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
<p>Home</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant={currentPage === "main" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "main" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("main")}>
|
<Button variant={currentPage === "history" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "history" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("history")}>
|
||||||
<HomeIcon size={20}/>
|
<HistoryIcon size={20}/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
<p>Home</p>
|
<p>History</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant={currentPage === "history" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "history" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("history")}>
|
<Button variant={currentPage === "settings" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "settings" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("settings")}>
|
||||||
<HistoryIcon size={20}/>
|
<SettingsIcon size={20}/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
<p>History</p>
|
<p>Settings</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant={currentPage === "audio-analysis" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-analysis" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-analysis")}>
|
<Button variant={currentPage === "debug" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "debug" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("debug")}>
|
||||||
<ActivityIcon size={20}/>
|
<TerminalIcon size={20} loop={true}/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
<p>Audio Quality Analyzer</p>
|
<p>Debug Logs</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip delayDuration={0}>
|
<DropdownMenu>
|
||||||
<TooltipTrigger asChild>
|
<Tooltip delayDuration={0}>
|
||||||
<Button variant={currentPage === "audio-converter" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-converter" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-converter")}>
|
<DropdownMenuTrigger asChild>
|
||||||
<FileMusicIcon size={20}/>
|
<TooltipTrigger asChild>
|
||||||
</Button>
|
<Button variant={["audio-analysis", "audio-converter", "file-manager"].includes(currentPage) ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${["audio-analysis", "audio-converter", "file-manager"].includes(currentPage) ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`}>
|
||||||
</TooltipTrigger>
|
<BlocksIcon size={20} loop={true}/>
|
||||||
<TooltipContent side="right">
|
</Button>
|
||||||
<p>Audio Converter</p>
|
</TooltipTrigger>
|
||||||
</TooltipContent>
|
</DropdownMenuTrigger>
|
||||||
</Tooltip>
|
<TooltipContent side="right">
|
||||||
|
<p>Tools</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<DropdownMenuContent side="right" sideOffset={14} className="min-w-[200px] ml-2">
|
||||||
|
<DropdownMenuItem onClick={() => onPageChange("audio-analysis")} className="gap-3 cursor-pointer py-2 px-3">
|
||||||
|
<ActivityIcon size={16}/>
|
||||||
|
<span>Audio Quality Analyzer</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onPageChange("audio-converter")} className="gap-3 cursor-pointer py-2 px-3">
|
||||||
|
<FileMusicIcon size={16}/>
|
||||||
|
<span>Audio Converter</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onPageChange("file-manager")} className="gap-3 cursor-pointer py-2 px-3">
|
||||||
|
<FilePenIcon size={16}/>
|
||||||
|
<span>File Manager</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Tooltip delayDuration={0}>
|
<div className="mt-auto flex flex-col gap-2">
|
||||||
<TooltipTrigger asChild>
|
<Tooltip delayDuration={0}>
|
||||||
<Button variant={currentPage === "file-manager" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "file-manager" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("file-manager")}>
|
<TooltipTrigger asChild>
|
||||||
<FilePenIcon size={20}/>
|
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues/268")}>
|
||||||
</Button>
|
<GithubIcon size={20}/>
|
||||||
</TooltipTrigger>
|
</Button>
|
||||||
<TooltipContent side="right">
|
</TooltipTrigger>
|
||||||
<p>File Manager</p>
|
<TooltipContent side="right">
|
||||||
</TooltipContent>
|
<p>Report Bugs or Request Features</p>
|
||||||
</Tooltip>
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant={currentPage === "debug" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "debug" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("debug")}>
|
<Button variant={currentPage === "about" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "about" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("about")}>
|
||||||
<TerminalIcon size={20} loop={true}/>
|
<BadgeAlertIcon size={20}/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
<p>Debug Logs</p>
|
<p>About</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant={currentPage === "settings" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "settings" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("settings")}>
|
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
||||||
<SettingsIcon size={20}/>
|
<CoffeeIcon size={20} loop={true}/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
<p>Settings</p>
|
<p>Support me on Ko-fi</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
</div>);
|
||||||
|
|
||||||
<div className="mt-auto flex flex-col gap-2">
|
|
||||||
<Tooltip delayDuration={0}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button variant={currentPage === "about" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "about" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("about")}>
|
|
||||||
<BadgeAlertIcon size={20}/>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">
|
|
||||||
<p>About</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<div className="relative group">
|
|
||||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary">
|
|
||||||
<CoffeeIcon size={20} loop={true}/>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
|
|
||||||
<div className="absolute left-10 bottom-0 w-4 h-full bg-transparent"/>
|
|
||||||
|
|
||||||
<div className="absolute left-10 bottom-0 mb-0 ml-3 hidden group-hover:flex flex-col gap-1 p-1 bg-popover border border-border rounded-md shadow-md z-50 w-max animate-in fade-in zoom-in-95 duration-200 origin-bottom-left">
|
|
||||||
<button onClick={() => openExternal("https://ko-fi.com/afkarxyz")} className="flex items-center gap-2 px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground rounded-sm transition-colors text-left w-full">
|
|
||||||
<img src={KofiLogo} className="h-4 w-4" alt="Ko-fi"/>
|
|
||||||
Support me on Ko-fi
|
|
||||||
</button>
|
|
||||||
<button onClick={() => openExternal("https://buymeacoffee.com/afkarxyz")} className="flex items-center gap-2 px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground rounded-sm transition-colors text-left w-full">
|
|
||||||
<img src={BmcLogo} className="h-4 w-4 dark:hidden" alt="BMC"/>
|
|
||||||
<img src={BmcLogoWhite} className="h-4 w-4 hidden dark:block" alt="BMC"/>
|
|
||||||
Buy Me a Coffee
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { X, Minus, Maximize, Settings, Info } from "lucide-react";
|
import { X, Minus, Maximize, SlidersHorizontal, Info } from "lucide-react";
|
||||||
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
||||||
import { Menubar, MenubarContent, MenubarMenu, MenubarItem, MenubarTrigger, MenubarLabel, MenubarSeparator } from "@/components/ui/menubar";
|
import { Menubar, MenubarContent, MenubarMenu, MenubarItem, MenubarTrigger, MenubarLabel, MenubarSeparator } from "@/components/ui/menubar";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
@@ -43,7 +43,7 @@ export function TitleBar() {
|
|||||||
<Menubar className="border-none bg-transparent shadow-none px-0 mr-1" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}>
|
<Menubar className="border-none bg-transparent shadow-none px-0 mr-1" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}>
|
||||||
<MenubarMenu>
|
<MenubarMenu>
|
||||||
<MenubarTrigger className="cursor-pointer w-8 h-7 p-0 flex items-center justify-center hover:bg-muted transition-colors rounded data-[state=open]:bg-muted">
|
<MenubarTrigger className="cursor-pointer w-8 h-7 p-0 flex items-center justify-center hover:bg-muted transition-colors rounded data-[state=open]:bg-muted">
|
||||||
<Settings className="w-3.5 h-3.5"/>
|
<SlidersHorizontal className="w-3.5 h-3.5"/>
|
||||||
</MenubarTrigger>
|
</MenubarTrigger>
|
||||||
<MenubarContent align="end" className="min-w-[200px]">
|
<MenubarContent align="end" className="min-w-[200px]">
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Download Lyric</p>
|
<p>Download Separate Lyric</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
{track.images && onDownloadCover && (<Tooltip>
|
{track.images && onDownloadCover && (<Tooltip>
|
||||||
@@ -129,7 +129,7 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Download Cover</p>
|
<p>Download Separate Cover</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
||||||
@@ -139,7 +139,7 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{availability ? (<div className="flex items-center gap-2">
|
{availability ? (<div className="flex items-center gap-2">
|
||||||
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`}/>
|
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`}/>
|
||||||
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
||||||
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`}/>
|
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`}/>
|
||||||
|
|||||||
@@ -116,6 +116,13 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
|||||||
return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0);
|
return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
else if (sortBy === "failed") {
|
||||||
|
filteredTracks = [...filteredTracks].sort((a, b) => {
|
||||||
|
const aFailed = a.spotify_id ? failedTracks.has(a.spotify_id) : false;
|
||||||
|
const bFailed = b.spotify_id ? failedTracks.has(b.spotify_id) : false;
|
||||||
|
return (bFailed ? 1 : 0) - (aFailed ? 1 : 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
const totalPages = Math.ceil(filteredTracks.length / itemsPerPage);
|
const totalPages = Math.ceil(filteredTracks.length / itemsPerPage);
|
||||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||||
const endIndex = startIndex + itemsPerPage;
|
const endIndex = startIndex + itemsPerPage;
|
||||||
@@ -297,7 +304,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Download Lyric</p>
|
<p>Download Separate Lyric</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
{track.images && onDownloadCover && (<Tooltip>
|
{track.images && onDownloadCover && (<Tooltip>
|
||||||
@@ -310,7 +317,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Download Cover</p>
|
<p>Download Separate Cover</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
import type { Variants } from "motion/react";
|
||||||
|
import { motion, useAnimation } from "motion/react";
|
||||||
|
import type { HTMLAttributes } from "react";
|
||||||
|
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
export interface BlocksIconHandle {
|
||||||
|
startAnimation: () => void;
|
||||||
|
stopAnimation: () => void;
|
||||||
|
}
|
||||||
|
interface BlocksIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
size?: number;
|
||||||
|
loop?: boolean;
|
||||||
|
}
|
||||||
|
const VARIANTS: Variants = {
|
||||||
|
normal: { translateX: 0, translateY: 0 },
|
||||||
|
animate: { translateX: -4, translateY: 4 },
|
||||||
|
};
|
||||||
|
const BlocksIcon = forwardRef<BlocksIconHandle, BlocksIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, loop = false, ...props }, ref) => {
|
||||||
|
const controls = useAnimation();
|
||||||
|
const isControlledRef = useRef(false);
|
||||||
|
useImperativeHandle(ref, () => {
|
||||||
|
isControlledRef.current = true;
|
||||||
|
return {
|
||||||
|
startAnimation: () => controls.start("animate"),
|
||||||
|
stopAnimation: () => controls.start("normal"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (isControlledRef.current) {
|
||||||
|
onMouseEnter?.(e);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
controls.start("animate");
|
||||||
|
}
|
||||||
|
}, [controls, onMouseEnter]);
|
||||||
|
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (isControlledRef.current) {
|
||||||
|
onMouseLeave?.(e);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
controls.start("normal");
|
||||||
|
}
|
||||||
|
}, [controls, onMouseLeave]);
|
||||||
|
return (<div className={cn("flex items-center justify-center", className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||||
|
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3"/>
|
||||||
|
<motion.path animate={controls} d="M14 3h7v7h-7z" variants={VARIANTS}/>
|
||||||
|
</svg>
|
||||||
|
</div>);
|
||||||
|
});
|
||||||
|
BlocksIcon.displayName = "BlocksIcon";
|
||||||
|
export { BlocksIcon };
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props}/>;
|
||||||
|
}
|
||||||
|
function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props}/>);
|
||||||
|
}
|
||||||
|
function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (<DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props}/>);
|
||||||
|
}
|
||||||
|
function DropdownMenuContent({ className, sideOffset = 4, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content data-slot="dropdown-menu-content" sideOffset={sideOffset} className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", className)} {...props}/>
|
||||||
|
</DropdownMenuPrimitive.Portal>);
|
||||||
|
}
|
||||||
|
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props}/>);
|
||||||
|
}
|
||||||
|
function DropdownMenuItem({ className, inset, variant = "default", ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
}) {
|
||||||
|
return (<DropdownMenuPrimitive.Item data-slot="dropdown-menu-item" data-inset={inset} data-variant={variant} className={cn("relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!", className)} {...props}/>);
|
||||||
|
}
|
||||||
|
function DropdownMenuCheckboxItem({ className, children, checked, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (<DropdownMenuPrimitive.CheckboxItem data-slot="dropdown-menu-checkbox-item" className={cn("relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} checked={checked} {...props}>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4"/>
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>);
|
||||||
|
}
|
||||||
|
function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (<DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props}/>);
|
||||||
|
}
|
||||||
|
function DropdownMenuRadioItem({ className, children, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (<DropdownMenuPrimitive.RadioItem data-slot="dropdown-menu-radio-item" className={cn("relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} {...props}>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current"/>
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>);
|
||||||
|
}
|
||||||
|
function DropdownMenuLabel({ className, inset, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (<DropdownMenuPrimitive.Label data-slot="dropdown-menu-label" data-inset={inset} className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)} {...props}/>);
|
||||||
|
}
|
||||||
|
function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (<DropdownMenuPrimitive.Separator data-slot="dropdown-menu-separator" className={cn("-mx-1 my-1 h-px bg-border", className)} {...props}/>);
|
||||||
|
}
|
||||||
|
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (<span data-slot="dropdown-menu-shortcut" className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props}/>);
|
||||||
|
}
|
||||||
|
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props}/>;
|
||||||
|
}
|
||||||
|
function DropdownMenuSubTrigger({ className, inset, children, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (<DropdownMenuPrimitive.SubTrigger data-slot="dropdown-menu-sub-trigger" data-inset={inset} className={cn("flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", className)} {...props}>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4"/>
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>);
|
||||||
|
}
|
||||||
|
function DropdownMenuSubContent({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (<DropdownMenuPrimitive.SubContent data-slot="dropdown-menu-sub-content" className={cn("z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", className)} {...props}/>);
|
||||||
|
}
|
||||||
|
export { DropdownMenu, DropdownMenuPortal, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, };
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
import type { Variants } from "motion/react";
|
||||||
|
import { motion, useAnimation } from "motion/react";
|
||||||
|
import type { HTMLAttributes } from "react";
|
||||||
|
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
export interface GithubIconHandle {
|
||||||
|
startAnimation: () => void;
|
||||||
|
stopAnimation: () => void;
|
||||||
|
}
|
||||||
|
interface GithubIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
const BODY_VARIANTS: Variants = {
|
||||||
|
normal: {
|
||||||
|
opacity: 1,
|
||||||
|
pathLength: 1,
|
||||||
|
scale: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 0.3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
opacity: [0, 1],
|
||||||
|
pathLength: [0, 1],
|
||||||
|
scale: [0.9, 1],
|
||||||
|
transition: {
|
||||||
|
duration: 0.4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const TAIL_VARIANTS: Variants = {
|
||||||
|
normal: {
|
||||||
|
pathLength: 1,
|
||||||
|
rotate: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
draw: {
|
||||||
|
pathLength: [0, 1],
|
||||||
|
rotate: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wag: {
|
||||||
|
pathLength: 1,
|
||||||
|
rotate: [0, -15, 15, -10, 10, -5, 5],
|
||||||
|
transition: {
|
||||||
|
duration: 2.5,
|
||||||
|
ease: "easeInOut",
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const GithubIcon = forwardRef<GithubIconHandle, GithubIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||||
|
const bodyControls = useAnimation();
|
||||||
|
const tailControls = useAnimation();
|
||||||
|
const isControlledRef = useRef(false);
|
||||||
|
useImperativeHandle(ref, () => {
|
||||||
|
isControlledRef.current = true;
|
||||||
|
return {
|
||||||
|
startAnimation: async () => {
|
||||||
|
bodyControls.start("animate");
|
||||||
|
await tailControls.start("draw");
|
||||||
|
tailControls.start("wag");
|
||||||
|
},
|
||||||
|
stopAnimation: () => {
|
||||||
|
bodyControls.start("normal");
|
||||||
|
tailControls.start("normal");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const handleMouseEnter = useCallback(async (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (isControlledRef.current) {
|
||||||
|
onMouseEnter?.(e);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
bodyControls.start("animate");
|
||||||
|
await tailControls.start("draw");
|
||||||
|
tailControls.start("wag");
|
||||||
|
}
|
||||||
|
}, [bodyControls, onMouseEnter, tailControls]);
|
||||||
|
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (isControlledRef.current) {
|
||||||
|
onMouseLeave?.(e);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
bodyControls.start("normal");
|
||||||
|
tailControls.start("normal");
|
||||||
|
}
|
||||||
|
}, [bodyControls, tailControls, onMouseLeave]);
|
||||||
|
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||||
|
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<motion.path animate={bodyControls} d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" initial="normal" variants={BODY_VARIANTS}/>
|
||||||
|
<motion.path animate={tailControls} d="M9 18c-4.51 2-5-2-7-2" initial="normal" variants={TAIL_VARIANTS}/>
|
||||||
|
</svg>
|
||||||
|
</div>);
|
||||||
|
});
|
||||||
|
GithubIcon.displayName = "GithubIcon";
|
||||||
|
export { GithubIcon };
|
||||||
@@ -2,7 +2,7 @@ import { useState, useRef } from "react";
|
|||||||
import { downloadCover } from "@/lib/api";
|
import { downloadCover } from "@/lib/api";
|
||||||
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
|
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { joinPath, sanitizePath } from "@/lib/utils";
|
import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import type { TrackMetadata } from "@/types/api";
|
import type { TrackMetadata } from "@/types/api";
|
||||||
export function useCover() {
|
export function useCover() {
|
||||||
@@ -29,17 +29,21 @@ export function useCover() {
|
|||||||
let outputDir = settings.downloadPath;
|
let outputDir = settings.downloadPath;
|
||||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||||
const yearValue = releaseDate?.substring(0, 4);
|
const yearValue = releaseDate?.substring(0, 4);
|
||||||
|
const displayArtist = settings.useFirstArtistOnly && artistName ? getFirstArtist(artistName) : artistName;
|
||||||
|
const displayAlbumArtist = settings.useFirstArtistOnly && albumArtist ? getFirstArtist(albumArtist) : albumArtist;
|
||||||
const templateData: TemplateData = {
|
const templateData: TemplateData = {
|
||||||
artist: artistName?.replace(/\//g, placeholder),
|
artist: displayArtist?.replace(/\//g, placeholder),
|
||||||
album: albumName?.replace(/\//g, placeholder),
|
album: albumName?.replace(/\//g, placeholder),
|
||||||
|
album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
|
||||||
title: trackName?.replace(/\//g, placeholder),
|
title: trackName?.replace(/\//g, placeholder),
|
||||||
track: position,
|
track: position,
|
||||||
year: yearValue,
|
year: yearValue,
|
||||||
|
date: releaseDate,
|
||||||
playlist: playlistName?.replace(/\//g, placeholder),
|
playlist: playlistName?.replace(/\//g, placeholder),
|
||||||
};
|
};
|
||||||
const folderTemplate = settings.folderTemplate || "";
|
const folderTemplate = settings.folderTemplate || "";
|
||||||
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
||||||
if (playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
if (settings.createPlaylistFolder && playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||||
}
|
}
|
||||||
if (settings.folderTemplate) {
|
if (settings.folderTemplate) {
|
||||||
@@ -55,9 +59,9 @@ export function useCover() {
|
|||||||
const response = await downloadCover({
|
const response = await downloadCover({
|
||||||
cover_url: coverUrl,
|
cover_url: coverUrl,
|
||||||
track_name: trackName,
|
track_name: trackName,
|
||||||
artist_name: artistName,
|
artist_name: displayArtist,
|
||||||
album_name: albumName || "",
|
album_name: albumName || "",
|
||||||
album_artist: albumArtist || "",
|
album_artist: displayAlbumArtist || "",
|
||||||
release_date: releaseDate || "",
|
release_date: releaseDate || "",
|
||||||
output_dir: outputDir,
|
output_dir: outputDir,
|
||||||
filename_format: settings.filenameTemplate || "{title}",
|
filename_format: settings.filenameTemplate || "{title}",
|
||||||
@@ -127,17 +131,21 @@ export function useCover() {
|
|||||||
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
||||||
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
|
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
|
||||||
const yearValue = track.release_date?.substring(0, 4);
|
const yearValue = track.release_date?.substring(0, 4);
|
||||||
|
const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists;
|
||||||
|
const displayAlbumArtist = settings.useFirstArtistOnly && track.album_artist ? getFirstArtist(track.album_artist) : track.album_artist;
|
||||||
const templateData: TemplateData = {
|
const templateData: TemplateData = {
|
||||||
artist: track.artists?.replace(/\//g, placeholder),
|
artist: displayArtist?.replace(/\//g, placeholder),
|
||||||
album: track.album_name?.replace(/\//g, placeholder),
|
album: track.album_name?.replace(/\//g, placeholder),
|
||||||
|
album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
|
||||||
title: track.name?.replace(/\//g, placeholder),
|
title: track.name?.replace(/\//g, placeholder),
|
||||||
track: trackPosition,
|
track: trackPosition,
|
||||||
year: yearValue,
|
year: yearValue,
|
||||||
|
date: track.release_date,
|
||||||
playlist: playlistName?.replace(/\//g, placeholder),
|
playlist: playlistName?.replace(/\//g, placeholder),
|
||||||
};
|
};
|
||||||
const folderTemplate = settings.folderTemplate || "";
|
const folderTemplate = settings.folderTemplate || "";
|
||||||
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
||||||
if (playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
if (settings.createPlaylistFolder && playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||||
}
|
}
|
||||||
if (settings.folderTemplate) {
|
if (settings.folderTemplate) {
|
||||||
@@ -153,9 +161,9 @@ export function useCover() {
|
|||||||
const response = await downloadCover({
|
const response = await downloadCover({
|
||||||
cover_url: track.images,
|
cover_url: track.images,
|
||||||
track_name: track.name,
|
track_name: track.name,
|
||||||
artist_name: track.artists,
|
artist_name: displayArtist,
|
||||||
album_name: track.album_name,
|
album_name: track.album_name,
|
||||||
album_artist: track.album_artist,
|
album_artist: displayAlbumArtist,
|
||||||
release_date: track.release_date,
|
release_date: track.release_date,
|
||||||
output_dir: outputDir,
|
output_dir: outputDir,
|
||||||
filename_format: settings.filenameTemplate || "{title}",
|
filename_format: settings.filenameTemplate || "{title}",
|
||||||
|
|||||||
@@ -2,16 +2,9 @@ import { useState, useRef } from "react";
|
|||||||
import { downloadTrack, fetchSpotifyMetadata } from "@/lib/api";
|
import { downloadTrack, fetchSpotifyMetadata } from "@/lib/api";
|
||||||
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
|
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { joinPath, sanitizePath } from "@/lib/utils";
|
import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import type { TrackMetadata } from "@/types/api";
|
import type { TrackMetadata } from "@/types/api";
|
||||||
function getFirstArtist(artistString: string): string {
|
|
||||||
if (!artistString)
|
|
||||||
return artistString;
|
|
||||||
const delimiters = /[,&]|(?:\s+(?:feat\.?|ft\.?|featuring)\s+)/i;
|
|
||||||
const parts = artistString.split(delimiters);
|
|
||||||
return parts[0].trim();
|
|
||||||
}
|
|
||||||
interface CheckFileExistenceRequest {
|
interface CheckFileExistenceRequest {
|
||||||
spotify_id: string;
|
spotify_id: string;
|
||||||
track_name: string;
|
track_name: string;
|
||||||
@@ -95,6 +88,7 @@ export function useDownload(region: string) {
|
|||||||
title: trackName?.replace(/\//g, placeholder),
|
title: trackName?.replace(/\//g, placeholder),
|
||||||
track: trackNumberForTemplate,
|
track: trackNumberForTemplate,
|
||||||
year: yearValue,
|
year: yearValue,
|
||||||
|
date: releaseDate,
|
||||||
playlist: playlistName?.replace(/\//g, placeholder),
|
playlist: playlistName?.replace(/\//g, placeholder),
|
||||||
};
|
};
|
||||||
const folderTemplate = settings.folderTemplate || "";
|
const folderTemplate = settings.folderTemplate || "";
|
||||||
@@ -166,9 +160,10 @@ export function useDownload(region: string) {
|
|||||||
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||||
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
||||||
let lastResponse: any = { success: false, error: "No matching services found" };
|
let lastResponse: any = { success: false, error: "No matching services found" };
|
||||||
|
const fallbackErrors: string[] = [];
|
||||||
const is24Bit = (settings.autoQuality || "24") === "24";
|
const is24Bit = (settings.autoQuality || "24") === "24";
|
||||||
const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
|
const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
|
||||||
const qobuzQuality = is24Bit ? "7" : "6";
|
const qobuzQuality = is24Bit ? "27" : "6";
|
||||||
for (const s of order) {
|
for (const s of order) {
|
||||||
if (s === "tidal" && streamingURLs?.tidal_url) {
|
if (s === "tidal" && streamingURLs?.tidal_url) {
|
||||||
try {
|
try {
|
||||||
@@ -201,16 +196,21 @@ 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,
|
||||||
|
embed_genre: settings.embedGenre,
|
||||||
});
|
});
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
logger.success(`tidal: ${trackName} - ${artistName}`);
|
logger.success(`tidal: ${trackName} - ${artistName}`);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
const errMsg = response.error || response.message || "Failed";
|
||||||
|
fallbackErrors.push(`[Tidal] ${errMsg}`);
|
||||||
lastResponse = response;
|
lastResponse = response;
|
||||||
logger.warning(`tidal failed, trying next...`);
|
logger.warning(`tidal failed, trying next...`);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
logger.error(`tidal error: ${err}`);
|
logger.error(`tidal error: ${err}`);
|
||||||
|
fallbackErrors.push(`[Tidal] ${String(err)}`);
|
||||||
lastResponse = { success: false, error: String(err) };
|
lastResponse = { success: false, error: String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,16 +242,21 @@ 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,
|
||||||
|
embed_genre: settings.embedGenre,
|
||||||
});
|
});
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
logger.success(`amazon: ${trackName} - ${artistName}`);
|
logger.success(`amazon: ${trackName} - ${artistName}`);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
const errMsg = response.error || response.message || "Failed";
|
||||||
|
fallbackErrors.push(`[Amazon] ${errMsg}`);
|
||||||
lastResponse = response;
|
lastResponse = response;
|
||||||
logger.warning(`amazon failed, trying next...`);
|
logger.warning(`amazon failed, trying next...`);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
logger.error(`amazon error: ${err}`);
|
logger.error(`amazon error: ${err}`);
|
||||||
|
fallbackErrors.push(`[Amazon] ${String(err)}`);
|
||||||
lastResponse = { success: false, error: String(err) };
|
lastResponse = { success: false, error: String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,23 +288,29 @@ 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,
|
||||||
|
embed_genre: settings.embedGenre,
|
||||||
});
|
});
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
logger.success(`qobuz: ${trackName} - ${artistName}`);
|
logger.success(`qobuz: ${trackName} - ${artistName}`);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
const errMsg = response.error || response.message || "Failed";
|
||||||
|
fallbackErrors.push(`[Qobuz] ${errMsg}`);
|
||||||
lastResponse = response;
|
lastResponse = response;
|
||||||
logger.warning(`qobuz failed, trying next...`);
|
logger.warning(`qobuz failed, trying next...`);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
logger.error(`qobuz error: ${err}`);
|
logger.error(`qobuz error: ${err}`);
|
||||||
|
fallbackErrors.push(`[Qobuz] ${String(err)}`);
|
||||||
lastResponse = { success: false, error: String(err) };
|
lastResponse = { success: false, error: String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (itemID) {
|
if (itemID) {
|
||||||
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
||||||
await MarkDownloadItemFailed(itemID, lastResponse.error || "All services failed");
|
const finalError = fallbackErrors.length > 0 ? fallbackErrors.join(" | ") : (lastResponse.error || "All services failed");
|
||||||
|
await MarkDownloadItemFailed(itemID, finalError);
|
||||||
}
|
}
|
||||||
return lastResponse;
|
return lastResponse;
|
||||||
}
|
}
|
||||||
@@ -311,6 +322,10 @@ export function useDownload(region: string) {
|
|||||||
else if (service === "qobuz") {
|
else if (service === "qobuz") {
|
||||||
audioFormat = settings.qobuzQuality || "6";
|
audioFormat = settings.qobuzQuality || "6";
|
||||||
}
|
}
|
||||||
|
else if (service === "deezer") {
|
||||||
|
audioFormat = "flac";
|
||||||
|
}
|
||||||
|
logger.debug(`trying ${service} for: ${trackName} - ${artistName}`);
|
||||||
const singleServiceResponse = await downloadTrack({
|
const singleServiceResponse = await downloadTrack({
|
||||||
service: service as "tidal" | "qobuz" | "amazon",
|
service: service as "tidal" | "qobuz" | "amazon",
|
||||||
query,
|
query,
|
||||||
@@ -337,6 +352,8 @@ 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,
|
||||||
|
embed_genre: settings.embedGenre,
|
||||||
});
|
});
|
||||||
if (!singleServiceResponse.success && itemID) {
|
if (!singleServiceResponse.success && itemID) {
|
||||||
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
||||||
@@ -385,6 +402,7 @@ export function useDownload(region: string) {
|
|||||||
title: trackName?.replace(/\//g, placeholder),
|
title: trackName?.replace(/\//g, placeholder),
|
||||||
track: trackNumberForTemplate,
|
track: trackNumberForTemplate,
|
||||||
year: yearValue,
|
year: yearValue,
|
||||||
|
date: releaseDate,
|
||||||
playlist: folderName?.replace(/\//g, placeholder),
|
playlist: folderName?.replace(/\//g, placeholder),
|
||||||
};
|
};
|
||||||
const folderTemplate = settings.folderTemplate || "";
|
const folderTemplate = settings.folderTemplate || "";
|
||||||
@@ -417,12 +435,14 @@ export function useDownload(region: string) {
|
|||||||
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||||
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
||||||
let lastResponse: any = { success: false, error: "No matching services found" };
|
let lastResponse: any = { success: false, error: "No matching services found" };
|
||||||
|
const fallbackErrors: string[] = [];
|
||||||
const is24Bit = (settings.autoQuality || "24") === "24";
|
const is24Bit = (settings.autoQuality || "24") === "24";
|
||||||
const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
|
const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
|
||||||
const qobuzQuality = is24Bit ? "7" : "6";
|
const qobuzQuality = is24Bit ? "27" : "6";
|
||||||
for (const s of order) {
|
for (const s of order) {
|
||||||
if (s === "tidal" && streamingURLs?.tidal_url) {
|
if (s === "tidal" && streamingURLs?.tidal_url) {
|
||||||
try {
|
try {
|
||||||
|
logger.debug(`trying tidal for: ${trackName} - ${artistName}`);
|
||||||
const response = await downloadTrack({
|
const response = await downloadTrack({
|
||||||
service: "tidal",
|
service: "tidal",
|
||||||
query,
|
query,
|
||||||
@@ -451,19 +471,27 @@ 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,
|
||||||
|
embed_genre: settings.embedGenre,
|
||||||
});
|
});
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
|
logger.success(`tidal: ${trackName} - ${artistName}`);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
const errMsg = response.error || response.message || "Failed";
|
||||||
|
fallbackErrors.push(`[Tidal] ${errMsg}`);
|
||||||
lastResponse = response;
|
lastResponse = response;
|
||||||
|
logger.warning(`tidal failed, trying next...`);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
console.error("Tidal error:", err);
|
logger.error(`tidal error: ${err}`);
|
||||||
|
fallbackErrors.push(`[Tidal] ${String(err)}`);
|
||||||
lastResponse = { success: false, error: String(err) };
|
lastResponse = { success: false, error: String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (s === "amazon" && streamingURLs?.amazon_url) {
|
else if (s === "amazon" && streamingURLs?.amazon_url) {
|
||||||
try {
|
try {
|
||||||
|
logger.debug(`trying amazon for: ${trackName} - ${artistName}`);
|
||||||
const response = await downloadTrack({
|
const response = await downloadTrack({
|
||||||
service: "amazon",
|
service: "amazon",
|
||||||
query,
|
query,
|
||||||
@@ -490,19 +518,27 @@ 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,
|
||||||
|
embed_genre: settings.embedGenre,
|
||||||
});
|
});
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
|
logger.success(`amazon: ${trackName} - ${artistName}`);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
const errMsg = response.error || response.message || "Failed";
|
||||||
|
fallbackErrors.push(`[Amazon] ${errMsg}`);
|
||||||
lastResponse = response;
|
lastResponse = response;
|
||||||
|
logger.warning(`amazon failed, trying next...`);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
console.error("Amazon error:", err);
|
logger.error(`amazon error: ${err}`);
|
||||||
|
fallbackErrors.push(`[Amazon] ${String(err)}`);
|
||||||
lastResponse = { success: false, error: String(err) };
|
lastResponse = { success: false, error: String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (s === "qobuz") {
|
else if (s === "qobuz") {
|
||||||
try {
|
try {
|
||||||
|
logger.debug(`trying qobuz for: ${trackName} - ${artistName}`);
|
||||||
const response = await downloadTrack({
|
const response = await downloadTrack({
|
||||||
service: "qobuz",
|
service: "qobuz",
|
||||||
query,
|
query,
|
||||||
@@ -530,21 +566,29 @@ 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,
|
||||||
|
embed_genre: settings.embedGenre,
|
||||||
});
|
});
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
|
logger.success(`qobuz: ${trackName} - ${artistName}`);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
const errMsg = response.error || response.message || "Failed";
|
||||||
|
fallbackErrors.push(`[Qobuz] ${errMsg}`);
|
||||||
lastResponse = response;
|
lastResponse = response;
|
||||||
|
logger.warning(`qobuz failed, trying next...`);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
console.error("Qobuz error:", err);
|
logger.error(`qobuz error: ${err}`);
|
||||||
|
fallbackErrors.push(`[Qobuz] ${String(err)}`);
|
||||||
lastResponse = { success: false, error: String(err) };
|
lastResponse = { success: false, error: String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!lastResponse.success && itemID) {
|
if (!lastResponse.success && itemID) {
|
||||||
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
||||||
await MarkDownloadItemFailed(itemID, lastResponse.error || "All services failed");
|
const finalError = fallbackErrors.length > 0 ? fallbackErrors.join(" | ") : (lastResponse.error || "All services failed");
|
||||||
|
await MarkDownloadItemFailed(itemID, finalError);
|
||||||
}
|
}
|
||||||
return lastResponse;
|
return lastResponse;
|
||||||
}
|
}
|
||||||
@@ -582,6 +626,9 @@ export function useDownload(region: string) {
|
|||||||
spotify_total_discs: spotifyTotalDiscs,
|
spotify_total_discs: spotifyTotalDiscs,
|
||||||
copyright: copyright,
|
copyright: copyright,
|
||||||
publisher: publisher,
|
publisher: publisher,
|
||||||
|
use_first_artist_only: settings.useFirstArtistOnly,
|
||||||
|
use_single_genre: settings.useSingleGenre,
|
||||||
|
embed_genre: settings.embedGenre,
|
||||||
});
|
});
|
||||||
if (!singleServiceResponse.success && itemID) {
|
if (!singleServiceResponse.success && itemID) {
|
||||||
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useRef } from "react";
|
|||||||
import { downloadLyrics } from "@/lib/api";
|
import { downloadLyrics } from "@/lib/api";
|
||||||
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
|
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { joinPath, sanitizePath } from "@/lib/utils";
|
import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import type { TrackMetadata } from "@/types/api";
|
import type { TrackMetadata } from "@/types/api";
|
||||||
export function useLyrics() {
|
export function useLyrics() {
|
||||||
@@ -26,17 +26,21 @@ export function useLyrics() {
|
|||||||
let outputDir = settings.downloadPath;
|
let outputDir = settings.downloadPath;
|
||||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||||
const yearValue = releaseDate?.substring(0, 4);
|
const yearValue = releaseDate?.substring(0, 4);
|
||||||
|
const displayArtist = settings.useFirstArtistOnly && artistName ? getFirstArtist(artistName) : artistName;
|
||||||
|
const displayAlbumArtist = settings.useFirstArtistOnly && albumArtist ? getFirstArtist(albumArtist) : albumArtist;
|
||||||
const templateData: TemplateData = {
|
const templateData: TemplateData = {
|
||||||
artist: artistName?.replace(/\//g, placeholder),
|
artist: displayArtist?.replace(/\//g, placeholder),
|
||||||
album: albumName?.replace(/\//g, placeholder),
|
album: albumName?.replace(/\//g, placeholder),
|
||||||
|
album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
|
||||||
title: trackName?.replace(/\//g, placeholder),
|
title: trackName?.replace(/\//g, placeholder),
|
||||||
track: position,
|
track: position,
|
||||||
year: yearValue,
|
year: yearValue,
|
||||||
|
date: releaseDate,
|
||||||
playlist: playlistName?.replace(/\//g, placeholder),
|
playlist: playlistName?.replace(/\//g, placeholder),
|
||||||
};
|
};
|
||||||
const folderTemplate = settings.folderTemplate || "";
|
const folderTemplate = settings.folderTemplate || "";
|
||||||
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
||||||
if (playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
if (settings.createPlaylistFolder && playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||||
}
|
}
|
||||||
if (settings.folderTemplate) {
|
if (settings.folderTemplate) {
|
||||||
@@ -53,9 +57,9 @@ export function useLyrics() {
|
|||||||
const response = await downloadLyrics({
|
const response = await downloadLyrics({
|
||||||
spotify_id: spotifyId,
|
spotify_id: spotifyId,
|
||||||
track_name: trackName,
|
track_name: trackName,
|
||||||
artist_name: artistName,
|
artist_name: displayArtist,
|
||||||
album_name: albumName,
|
album_name: albumName,
|
||||||
album_artist: albumArtist,
|
album_artist: displayAlbumArtist,
|
||||||
release_date: releaseDate,
|
release_date: releaseDate,
|
||||||
output_dir: outputDir,
|
output_dir: outputDir,
|
||||||
filename_format: settings.filenameTemplate || "{title}",
|
filename_format: settings.filenameTemplate || "{title}",
|
||||||
@@ -123,17 +127,21 @@ export function useLyrics() {
|
|||||||
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
||||||
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
|
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
|
||||||
const yearValue = track.release_date?.substring(0, 4);
|
const yearValue = track.release_date?.substring(0, 4);
|
||||||
|
const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists;
|
||||||
|
const displayAlbumArtist = settings.useFirstArtistOnly && track.album_artist ? getFirstArtist(track.album_artist) : track.album_artist;
|
||||||
const templateData: TemplateData = {
|
const templateData: TemplateData = {
|
||||||
artist: track.artists?.replace(/\//g, placeholder),
|
artist: displayArtist?.replace(/\//g, placeholder),
|
||||||
album: track.album_name?.replace(/\//g, placeholder),
|
album: track.album_name?.replace(/\//g, placeholder),
|
||||||
|
album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
|
||||||
title: track.name?.replace(/\//g, placeholder),
|
title: track.name?.replace(/\//g, placeholder),
|
||||||
track: trackPosition,
|
track: trackPosition,
|
||||||
year: yearValue,
|
year: yearValue,
|
||||||
|
date: track.release_date,
|
||||||
playlist: playlistName?.replace(/\//g, placeholder),
|
playlist: playlistName?.replace(/\//g, placeholder),
|
||||||
};
|
};
|
||||||
const folderTemplate = settings.folderTemplate || "";
|
const folderTemplate = settings.folderTemplate || "";
|
||||||
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
||||||
if (playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
if (settings.createPlaylistFolder && playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||||
}
|
}
|
||||||
if (settings.folderTemplate) {
|
if (settings.folderTemplate) {
|
||||||
@@ -149,9 +157,9 @@ export function useLyrics() {
|
|||||||
const response = await downloadLyrics({
|
const response = await downloadLyrics({
|
||||||
spotify_id: id,
|
spotify_id: id,
|
||||||
track_name: track.name,
|
track_name: track.name,
|
||||||
artist_name: track.artists,
|
artist_name: displayArtist,
|
||||||
album_name: track.album_name,
|
album_name: track.album_name,
|
||||||
album_artist: track.album_artist,
|
album_artist: displayAlbumArtist,
|
||||||
release_date: track.release_date,
|
release_date: track.release_date,
|
||||||
output_dir: outputDir,
|
output_dir: outputDir,
|
||||||
filename_format: settings.filenameTemplate || "{title}",
|
filename_format: settings.filenameTemplate || "{title}",
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ export interface Settings {
|
|||||||
embedMaxQualityCover: boolean;
|
embedMaxQualityCover: boolean;
|
||||||
operatingSystem: "Windows" | "linux/MacOS";
|
operatingSystem: "Windows" | "linux/MacOS";
|
||||||
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
|
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
|
||||||
qobuzQuality: "6" | "7";
|
qobuzQuality: "6" | "7" | "27";
|
||||||
amazonQuality: "original";
|
amazonQuality: "original";
|
||||||
autoOrder: "tidal-qobuz-amazon" | "tidal-amazon-qobuz" | "qobuz-tidal-amazon" | "qobuz-amazon-tidal" | "amazon-tidal-qobuz" | "amazon-qobuz-tidal" | "tidal-qobuz" | "tidal-amazon" | "qobuz-tidal" | "qobuz-amazon" | "amazon-tidal" | "amazon-qobuz";
|
autoOrder: "tidal-qobuz-amazon" | "tidal-amazon-qobuz" | "qobuz-tidal-amazon" | "qobuz-amazon-tidal" | "amazon-tidal-qobuz" | "amazon-qobuz-tidal" | string;
|
||||||
autoQuality: "16" | "24";
|
autoQuality: "16" | "24";
|
||||||
allowFallback: boolean;
|
allowFallback: boolean;
|
||||||
useSpotFetchAPI: boolean;
|
useSpotFetchAPI: boolean;
|
||||||
@@ -31,6 +31,9 @@ export interface Settings {
|
|||||||
createPlaylistFolder: boolean;
|
createPlaylistFolder: boolean;
|
||||||
createM3u8File: boolean;
|
createM3u8File: boolean;
|
||||||
useFirstArtistOnly: boolean;
|
useFirstArtistOnly: boolean;
|
||||||
|
useSingleGenre: boolean;
|
||||||
|
embedGenre: boolean;
|
||||||
|
separator: "comma" | "semicolon";
|
||||||
}
|
}
|
||||||
export const FOLDER_PRESETS: Record<FolderPreset, {
|
export const FOLDER_PRESETS: Record<FolderPreset, {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -78,6 +81,7 @@ export const TEMPLATE_VARIABLES = [
|
|||||||
{ key: "{track}", description: "Track number", example: "01" },
|
{ key: "{track}", description: "Track number", example: "01" },
|
||||||
{ key: "{disc}", description: "Disc number", example: "1" },
|
{ key: "{disc}", description: "Disc number", example: "1" },
|
||||||
{ key: "{year}", description: "Release year", example: "2014" },
|
{ key: "{year}", description: "Release year", example: "2014" },
|
||||||
|
{ key: "{date}", description: "Release date (YYYY-MM-DD)", example: "2014-10-27" },
|
||||||
];
|
];
|
||||||
function detectOS(): "Windows" | "linux/MacOS" {
|
function detectOS(): "Windows" | "linux/MacOS" {
|
||||||
const platform = window.navigator.platform.toLowerCase();
|
const platform = window.navigator.platform.toLowerCase();
|
||||||
@@ -111,7 +115,10 @@ 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,
|
||||||
|
embedGenre: true,
|
||||||
|
separator: "semicolon"
|
||||||
};
|
};
|
||||||
export const FONT_OPTIONS: {
|
export const FONT_OPTIONS: {
|
||||||
value: FontFamily;
|
value: FontFamily;
|
||||||
@@ -206,9 +213,6 @@ function getSettingsFromLocalStorage(): Settings {
|
|||||||
if (!('qobuzQuality' in parsed)) {
|
if (!('qobuzQuality' in parsed)) {
|
||||||
parsed.qobuzQuality = "6";
|
parsed.qobuzQuality = "6";
|
||||||
}
|
}
|
||||||
if (parsed.qobuzQuality === "27") {
|
|
||||||
parsed.qobuzQuality = "6";
|
|
||||||
}
|
|
||||||
if (!('amazonQuality' in parsed)) {
|
if (!('amazonQuality' in parsed)) {
|
||||||
parsed.amazonQuality = "original";
|
parsed.amazonQuality = "original";
|
||||||
}
|
}
|
||||||
@@ -221,6 +225,9 @@ function getSettingsFromLocalStorage(): Settings {
|
|||||||
if (!('allowFallback' in parsed)) {
|
if (!('allowFallback' in parsed)) {
|
||||||
parsed.allowFallback = true;
|
parsed.allowFallback = true;
|
||||||
}
|
}
|
||||||
|
if (!('separator' in parsed)) {
|
||||||
|
parsed.separator = "semicolon";
|
||||||
|
}
|
||||||
return { ...DEFAULT_SETTINGS, ...parsed };
|
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -285,9 +292,6 @@ export async function loadSettings(): Promise<Settings> {
|
|||||||
if (!('qobuzQuality' in parsed)) {
|
if (!('qobuzQuality' in parsed)) {
|
||||||
parsed.qobuzQuality = "6";
|
parsed.qobuzQuality = "6";
|
||||||
}
|
}
|
||||||
if (parsed.qobuzQuality === "27") {
|
|
||||||
parsed.qobuzQuality = "6";
|
|
||||||
}
|
|
||||||
if (!('amazonQuality' in parsed)) {
|
if (!('amazonQuality' in parsed)) {
|
||||||
parsed.amazonQuality = "original";
|
parsed.amazonQuality = "original";
|
||||||
}
|
}
|
||||||
@@ -309,6 +313,15 @@ 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;
|
||||||
|
}
|
||||||
|
if (!('embedGenre' in parsed)) {
|
||||||
|
parsed.embedGenre = true;
|
||||||
|
}
|
||||||
|
if (!('separator' in parsed)) {
|
||||||
|
parsed.separator = "semicolon";
|
||||||
|
}
|
||||||
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
|
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
|
||||||
return cachedSettings!;
|
return cachedSettings!;
|
||||||
}
|
}
|
||||||
@@ -334,6 +347,7 @@ export interface TemplateData {
|
|||||||
track?: number;
|
track?: number;
|
||||||
disc?: number;
|
disc?: number;
|
||||||
year?: string;
|
year?: string;
|
||||||
|
date?: string;
|
||||||
playlist?: string;
|
playlist?: string;
|
||||||
}
|
}
|
||||||
export function parseTemplate(template: string, data: TemplateData): string {
|
export function parseTemplate(template: string, data: TemplateData): string {
|
||||||
@@ -347,6 +361,7 @@ export function parseTemplate(template: string, data: TemplateData): string {
|
|||||||
result = result.replace(/\{track\}/g, data.track ? String(data.track).padStart(2, "0") : "00");
|
result = result.replace(/\{track\}/g, data.track ? String(data.track).padStart(2, "0") : "00");
|
||||||
result = result.replace(/\{disc\}/g, data.disc ? String(data.disc) : "1");
|
result = result.replace(/\{disc\}/g, data.disc ? String(data.disc) : "1");
|
||||||
result = result.replace(/\{year\}/g, data.year || "0000");
|
result = result.replace(/\{year\}/g, data.year || "0000");
|
||||||
|
result = result.replace(/\{date\}/g, data.date || "0000-00-00");
|
||||||
result = result.replace(/\{playlist\}/g, data.playlist || "");
|
result = result.replace(/\{playlist\}/g, data.playlist || "");
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,3 +46,10 @@ export function openExternal(url: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export function getFirstArtist(artistString: string): string {
|
||||||
|
if (!artistString)
|
||||||
|
return artistString;
|
||||||
|
const delimiters = /[,&]|(?:\s+(?:feat\.?|ft\.?|featuring)\s+)/i;
|
||||||
|
const parts = artistString.split(delimiters);
|
||||||
|
return parts[0].trim();
|
||||||
|
}
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ 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;
|
||||||
|
embed_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{
|
||||||
|
|||||||
+2
-2
@@ -12,10 +12,10 @@
|
|||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"productName": "SpotiFLAC",
|
"productName": "SpotiFLAC",
|
||||||
"productVersion": "7.0.9",
|
"productVersion": "7.1.1",
|
||||||
"copyright": "© 2026 afkarxyz"
|
"copyright": "© 2026 afkarxyz"
|
||||||
},
|
},
|
||||||
"wailsjsdir": "./frontend",
|
"wailsjsdir": "./frontend",
|
||||||
"assetdir": "./frontend/dist",
|
"assetdir": "./frontend/dist",
|
||||||
"reloaddirs": "./frontend/src"
|
"reloaddirs": "./frontend/src"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user