v7.1.1
This commit is contained in:
@@ -1 +1,2 @@
|
||||
ko_fi: afkarxyz
|
||||
patreon: afkarxyz
|
||||
@@ -24,14 +24,12 @@ Get Spotify tracks in true Lossless from Tidal, Qobuz, Amazon Music, Deezer & Ap
|
||||
|
||||
Download Spotify Tracks, Albums, Playlists & Discography as MP3/OGG/Opus.
|
||||
|
||||
## Related projects
|
||||
|
||||
### [SpotiFLAC (Mobile)](https://github.com/zarzet/SpotiFLAC-Mobile)
|
||||
|
||||
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)
|
||||
|
||||
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
||||
|
||||
SpotiFLAC Python library for SpotiFLAC integration — maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu)
|
||||
@@ -108,7 +106,7 @@ The software is provided "as is", without warranty of any kind. The author assum
|
||||
|
||||
## API Credits
|
||||
|
||||
[MusicBrainz](https://musicbrainz.org) · [LRCLIB](https://lrclib.net) · [Songlink/Odesli](https://song.link) · [hifi-api](https://github.com/binimum/hifi-api) · [dabmusic.xyz](https://dabmusic.xyz) · [musicdl.me](https://musicdl.me)
|
||||
[MusicBrainz](https://musicbrainz.org) · [LRCLIB](https://lrclib.net) · [Songlink/Odesli](https://song.link) · [hifi-api](https://github.com/binimum/hifi-api) · [WJHE](https://music.wjhe.top) · [GDStudio](https://music.gdstudio.xyz) · [MusicDL](https://musicdl.me)
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
|
||||
@@ -33,12 +33,41 @@ type CurrentIPInfo struct {
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
type APIStatusTargetResult struct {
|
||||
Target string `json:"target"`
|
||||
Label string `json:"label"`
|
||||
Online bool `json:"online"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type APIStatusReport struct {
|
||||
Type string `json:"type"`
|
||||
Online bool `json:"online"`
|
||||
RequireAll bool `json:"require_all"`
|
||||
Details []APIStatusTargetResult `json:"details"`
|
||||
}
|
||||
|
||||
const checkOperationTimeout = 10 * time.Second
|
||||
|
||||
func NewApp() *App {
|
||||
return &App{}
|
||||
}
|
||||
|
||||
func (a *App) LogStatusConsole(level string, message string) {
|
||||
normalizedLevel := strings.ToLower(strings.TrimSpace(level))
|
||||
if normalizedLevel == "" {
|
||||
normalizedLevel = "info"
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("[%s] [%s] %s\n", time.Now().Format("15:04:05"), normalizedLevel, strings.TrimSpace(message))
|
||||
switch normalizedLevel {
|
||||
case "error":
|
||||
_, _ = fmt.Fprint(os.Stderr, line)
|
||||
default:
|
||||
fmt.Print(line)
|
||||
}
|
||||
}
|
||||
|
||||
type timedResult[T any] struct {
|
||||
value T
|
||||
err error
|
||||
@@ -276,11 +305,12 @@ func (a *App) startup(ctx context.Context) {
|
||||
if err := backend.InitProviderPriorityDB(); err != nil {
|
||||
fmt.Printf("Failed to init provider priority DB: %v\n", err)
|
||||
}
|
||||
go func() {
|
||||
if err := backend.PrimeTidalAPIList(); err != nil {
|
||||
fmt.Printf("Failed to prime Tidal API list: %v\n", err)
|
||||
if err := backend.CleanupLegacyTidalPublicAPIState(); err != nil {
|
||||
fmt.Printf("Failed to clean legacy Tidal API cache: %v\n", err)
|
||||
}
|
||||
if err := backend.SanitizePersistedConfigSettings(); err != nil {
|
||||
fmt.Printf("Failed to sanitize persisted config settings: %v\n", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *App) shutdown(ctx context.Context) {
|
||||
@@ -662,21 +692,16 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
}
|
||||
|
||||
case "tidal":
|
||||
if req.TidalAPIURL == "" || req.TidalAPIURL == "auto" {
|
||||
downloader := backend.NewTidalDownloader("")
|
||||
if req.ServiceURL != "" {
|
||||
filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||
} 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, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||
if !strings.HasPrefix(strings.TrimRight(strings.TrimSpace(req.TidalAPIURL), "/"), "https://") {
|
||||
err = fmt.Errorf("a configured HTTPS Tidal instance is required")
|
||||
break
|
||||
}
|
||||
} else {
|
||||
downloader := backend.NewTidalDownloader(req.TidalAPIURL)
|
||||
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, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||
} 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, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||
}
|
||||
}
|
||||
|
||||
case "qobuz":
|
||||
|
||||
@@ -986,15 +1011,7 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
|
||||
isOnline, err := runWithTimeout(checkOperationTimeout, func() (bool, error) {
|
||||
switch apiType {
|
||||
case "tidal":
|
||||
if checkGroupedAPIStatus("tidal", buildTidalStatusCheckURLs(apiURL)) {
|
||||
return true, nil
|
||||
}
|
||||
if strings.TrimSpace(apiURL) == "" {
|
||||
if _, refreshErr := backend.RefreshTidalAPIList(true); refreshErr == nil && checkGroupedAPIStatus("tidal", buildTidalStatusCheckURLs("")) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
return checkGroupedAPIStatus("tidal", buildTidalStatusCheckURLs(apiURL)), nil
|
||||
case "qobuz", "qbz":
|
||||
return checkGroupedAPIStatus("qobuz", buildQobuzStatusCheckURLs(apiURL)), nil
|
||||
case "amazon":
|
||||
@@ -1022,6 +1039,39 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
|
||||
return isOnline
|
||||
}
|
||||
|
||||
func (a *App) CheckAPIStatusReport(apiType string, apiURL string) APIStatusReport {
|
||||
report, err := runWithTimeout(checkOperationTimeout, func() (APIStatusReport, error) {
|
||||
switch apiType {
|
||||
case "tidal":
|
||||
return buildGroupedAPIStatusReport("tidal", buildTidalStatusCheckURLs(apiURL), false), nil
|
||||
case "qobuz", "qbz":
|
||||
return buildGroupedAPIStatusReport("qobuz", buildQobuzStatusCheckURLs(apiURL), false), nil
|
||||
case "amazon":
|
||||
return buildGroupedAPIStatusReport("amazon", buildAmazonStatusCheckURLs(apiURL), false), nil
|
||||
case "lrclib":
|
||||
return buildGroupedAPIStatusReport("lrclib", buildLRCLIBStatusCheckURLs(apiURL), false), nil
|
||||
case "musicbrainz":
|
||||
return buildGroupedAPIStatusReport("musicbrainz", buildMusicBrainzStatusCheckURLs(apiURL), false), nil
|
||||
default:
|
||||
return buildGroupedAPIStatusReport(apiType, []string{strings.TrimSpace(apiURL)}, false), nil
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return APIStatusReport{
|
||||
Type: apiType,
|
||||
Online: false,
|
||||
RequireAll: apiType == "qobuz" || apiType == "qbz",
|
||||
Details: []APIStatusTargetResult{{
|
||||
Target: strings.TrimSpace(apiURL),
|
||||
Label: describeAPIStatusTarget(apiType, apiURL),
|
||||
Online: false,
|
||||
Message: err.Error(),
|
||||
}},
|
||||
}
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func (a *App) CheckCustomTidalAPI(apiURL string) bool {
|
||||
type tidalProbeResponse struct {
|
||||
Version string `json:"version"`
|
||||
@@ -1108,46 +1158,18 @@ func (a *App) CheckCustomTidalAPI(apiURL string) bool {
|
||||
|
||||
func buildTidalStatusCheckURLs(apiURL string) []string {
|
||||
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||
if apiURL != "" {
|
||||
if apiURL == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL)}
|
||||
}
|
||||
|
||||
apis, err := backend.GetRotatedTidalAPIList()
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to load rotated Tidal API list for status check: %v\n", err)
|
||||
}
|
||||
|
||||
urls := make([]string, 0, len(apis))
|
||||
for _, baseURL := range apis {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" {
|
||||
continue
|
||||
}
|
||||
urls = append(urls, fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", baseURL))
|
||||
}
|
||||
|
||||
return urls
|
||||
}
|
||||
|
||||
func buildQobuzStatusCheckURLs(apiURL string) []string {
|
||||
if trimmed := strings.TrimSpace(apiURL); trimmed != "" {
|
||||
return []string{buildQobuzStatusCheckURL(trimmed)}
|
||||
return []string{trimmed}
|
||||
}
|
||||
|
||||
bases := backend.GetQobuzStreamAPIBaseURLs()
|
||||
urls := make([]string, 0, len(bases)+1)
|
||||
for _, baseURL := range bases {
|
||||
urls = append(urls, buildQobuzStatusCheckURL(baseURL))
|
||||
}
|
||||
if musicDLURL := strings.TrimSpace(backend.GetQobuzMusicDLDownloadAPIURL()); musicDLURL != "" {
|
||||
urls = append(urls, musicDLURL)
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
func buildQobuzStatusCheckURL(apiBase string) string {
|
||||
apiBase = strings.TrimSpace(apiBase)
|
||||
return fmt.Sprintf("%s360735657&quality=27", apiBase)
|
||||
return backend.GetQobuzDownloadProviderURLs()
|
||||
}
|
||||
|
||||
func buildAmazonStatusCheckURLs(apiURL string) []string {
|
||||
@@ -1213,10 +1235,222 @@ func checkGroupedAPIStatus(apiType string, checkURLs []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func buildGroupedAPIStatusReport(apiType string, checkURLs []string, requireAll bool) APIStatusReport {
|
||||
filtered := make([]string, 0, len(checkURLs))
|
||||
for _, rawURL := range checkURLs {
|
||||
target := strings.TrimSpace(rawURL)
|
||||
if target == "" {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, target)
|
||||
}
|
||||
|
||||
report := APIStatusReport{
|
||||
Type: apiType,
|
||||
Online: !requireAll,
|
||||
RequireAll: requireAll,
|
||||
Details: make([]APIStatusTargetResult, len(filtered)),
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
report.Online = false
|
||||
return report
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for index, target := range filtered {
|
||||
wg.Add(1)
|
||||
go func(idx int, rawTarget string) {
|
||||
defer wg.Done()
|
||||
report.Details[idx] = checkSingleAPIStatusDetailed(apiType, rawTarget)
|
||||
}(index, target)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if requireAll {
|
||||
report.Online = true
|
||||
for _, detail := range report.Details {
|
||||
if !detail.Online {
|
||||
report.Online = false
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
report.Online = false
|
||||
for _, detail := range report.Details {
|
||||
if detail.Online {
|
||||
report.Online = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
func checkAllGroupedAPIStatus(apiType string, checkURLs []string) bool {
|
||||
filtered := make([]string, 0, len(checkURLs))
|
||||
for _, rawURL := range checkURLs {
|
||||
url := strings.TrimSpace(rawURL)
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, url)
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
results := make(chan bool, len(filtered))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, checkURL := range filtered {
|
||||
wg.Add(1)
|
||||
go func(target string) {
|
||||
defer wg.Done()
|
||||
results <- checkSingleAPIStatus(apiType, target)
|
||||
}(checkURL)
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(results)
|
||||
}()
|
||||
|
||||
for online := range results {
|
||||
if !online {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func describeAPIStatusTarget(apiType string, checkURL string) string {
|
||||
trimmedType := strings.TrimSpace(strings.ToLower(apiType))
|
||||
trimmedURL := strings.TrimSpace(checkURL)
|
||||
|
||||
if trimmedType == "qobuz" || trimmedType == "qbz" {
|
||||
switch {
|
||||
case backend.IsQobuzWJHEProviderURL(trimmedURL):
|
||||
return "WJHE"
|
||||
case backend.IsQobuzMusicDLProviderURL(trimmedURL):
|
||||
return "MusicDL"
|
||||
case backend.IsQobuzGDStudioProviderURL(trimmedURL):
|
||||
parsed, err := url.Parse(trimmedURL)
|
||||
if err == nil {
|
||||
host := strings.ToLower(strings.TrimSpace(parsed.Host))
|
||||
switch {
|
||||
case strings.Contains(host, "xyz"):
|
||||
return "GDStudio XYZ"
|
||||
case strings.Contains(host, "org"):
|
||||
return "GDStudio ORG"
|
||||
}
|
||||
}
|
||||
return "GDStudio"
|
||||
}
|
||||
}
|
||||
|
||||
if trimmedURL != "" {
|
||||
if parsed, err := url.Parse(trimmedURL); err == nil && strings.TrimSpace(parsed.Host) != "" {
|
||||
return strings.TrimSpace(parsed.Host)
|
||||
}
|
||||
}
|
||||
|
||||
if trimmedType == "" {
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
return strings.ToUpper(trimmedType)
|
||||
}
|
||||
|
||||
func checkSingleAPIStatusDetailed(apiType string, checkURL string) APIStatusTargetResult {
|
||||
result := APIStatusTargetResult{
|
||||
Target: strings.TrimSpace(checkURL),
|
||||
Label: describeAPIStatusTarget(apiType, checkURL),
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 4 * time.Second}
|
||||
trimmedType := strings.TrimSpace(strings.ToLower(apiType))
|
||||
|
||||
if trimmedType == "qobuz" || trimmedType == "qbz" {
|
||||
var err error
|
||||
switch {
|
||||
case backend.IsQobuzWJHEProviderURL(checkURL):
|
||||
err = backend.CheckQobuzWJHEStatusDetailed(client)
|
||||
case backend.IsQobuzMusicDLProviderURL(checkURL):
|
||||
err = backend.CheckQobuzMusicDLStatusDetailed(client)
|
||||
case backend.IsQobuzGDStudioProviderURL(checkURL):
|
||||
err = backend.CheckQobuzGDStudioAPIStatusDetailed(client, checkURL)
|
||||
default:
|
||||
err = fmt.Errorf("unknown qobuz provider url: %s", strings.TrimSpace(checkURL))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
result.Message = err.Error()
|
||||
return result
|
||||
}
|
||||
|
||||
result.Online = true
|
||||
result.Message = "stream URL resolved"
|
||||
return result
|
||||
}
|
||||
|
||||
req, err := backend.NewRequestWithDefaultHeaders(http.MethodGet, checkURL, nil)
|
||||
if err != nil {
|
||||
result.Message = fmt.Sprintf("failed to create request: %v", err)
|
||||
return result
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
result.Message = fmt.Sprintf("request failed: %v", err)
|
||||
return result
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
if err != nil {
|
||||
result.Message = fmt.Sprintf("failed to read response: %v", err)
|
||||
return result
|
||||
}
|
||||
|
||||
switch trimmedType {
|
||||
case "amazon":
|
||||
if resp.StatusCode == http.StatusOK && strings.Contains(string(body), `"amazonMusic":"up"`) {
|
||||
result.Online = true
|
||||
result.Message = `amazonMusic="up"`
|
||||
return result
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
result.Message = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, previewResponseBody(body, 160))
|
||||
return result
|
||||
}
|
||||
result.Message = `amazonMusic was not reported as "up"`
|
||||
return result
|
||||
default:
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
result.Online = true
|
||||
result.Message = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
return result
|
||||
}
|
||||
result.Message = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, previewResponseBody(body, 160))
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
func checkSingleAPIStatus(apiType string, checkURL string) bool {
|
||||
client := &http.Client{Timeout: 4 * time.Second}
|
||||
if (apiType == "qobuz" || apiType == "qbz") && strings.EqualFold(strings.TrimSpace(checkURL), strings.TrimSpace(backend.GetQobuzMusicDLDownloadAPIURL())) {
|
||||
if apiType == "qobuz" || apiType == "qbz" {
|
||||
switch {
|
||||
case backend.IsQobuzWJHEProviderURL(checkURL):
|
||||
return backend.CheckQobuzWJHEStatus(client)
|
||||
case backend.IsQobuzMusicDLProviderURL(checkURL):
|
||||
return backend.CheckQobuzMusicDLStatus(client)
|
||||
case backend.IsQobuzGDStudioProviderURL(checkURL):
|
||||
return backend.CheckQobuzGDStudioAPIStatus(client, checkURL)
|
||||
}
|
||||
}
|
||||
|
||||
req, err := backend.NewRequestWithDefaultHeaders(http.MethodGet, checkURL, nil)
|
||||
@@ -2045,6 +2279,7 @@ func (a *App) SaveSettings(settings map[string]interface{}) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings = backend.SanitizeSettingsMap(settings)
|
||||
|
||||
dir := filepath.Dir(configPath)
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
@@ -2102,7 +2337,7 @@ func (a *App) LoadSettings() (map[string]interface{}, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return settings, nil
|
||||
return backend.SanitizeSettingsMap(settings), nil
|
||||
}
|
||||
|
||||
func (a *App) LoadFonts() ([]map[string]interface{}, error) {
|
||||
|
||||
+129
-8
@@ -2,11 +2,138 @@ package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const legacyTidalAPICacheFile = "tidal-api-urls.json"
|
||||
|
||||
func normalizeCustomTidalAPIValue(value interface{}) string {
|
||||
customAPI, _ := value.(string)
|
||||
customAPI = strings.TrimRight(strings.TrimSpace(customAPI), "/")
|
||||
if strings.HasPrefix(customAPI, "https://") {
|
||||
return customAPI
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sanitizeDownloaderValue(value interface{}, allowTidal bool) string {
|
||||
downloader, _ := value.(string)
|
||||
switch strings.TrimSpace(strings.ToLower(downloader)) {
|
||||
case "tidal":
|
||||
if allowTidal {
|
||||
return "tidal"
|
||||
}
|
||||
return "auto"
|
||||
case "qobuz":
|
||||
return "qobuz"
|
||||
case "amazon":
|
||||
return "amazon"
|
||||
default:
|
||||
return "auto"
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeAutoOrderValue(value interface{}, allowTidal bool) string {
|
||||
autoOrder, _ := value.(string)
|
||||
allowed := map[string]struct{}{
|
||||
"qobuz": {},
|
||||
"amazon": {},
|
||||
}
|
||||
fallback := "qobuz-amazon"
|
||||
if allowTidal {
|
||||
allowed["tidal"] = struct{}{}
|
||||
fallback = "tidal-qobuz-amazon"
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
parts := make([]string, 0, 3)
|
||||
for _, rawPart := range strings.Split(strings.TrimSpace(strings.ToLower(autoOrder)), "-") {
|
||||
part := strings.TrimSpace(rawPart)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[part]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[part]; ok {
|
||||
continue
|
||||
}
|
||||
seen[part] = struct{}{}
|
||||
parts = append(parts, part)
|
||||
}
|
||||
|
||||
if len(parts) < 2 {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return strings.Join(parts, "-")
|
||||
}
|
||||
|
||||
func SanitizeSettingsMap(settings map[string]interface{}) map[string]interface{} {
|
||||
if settings == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
sanitized := make(map[string]interface{}, len(settings))
|
||||
for key, value := range settings {
|
||||
sanitized[key] = value
|
||||
}
|
||||
|
||||
customAPI := normalizeCustomTidalAPIValue(sanitized["customTidalApi"])
|
||||
sanitized["customTidalApi"] = customAPI
|
||||
allowTidal := customAPI != ""
|
||||
sanitized["downloader"] = sanitizeDownloaderValue(sanitized["downloader"], allowTidal)
|
||||
sanitized["autoOrder"] = sanitizeAutoOrderValue(sanitized["autoOrder"], allowTidal)
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func CleanupLegacyTidalPublicAPIState() error {
|
||||
appDir, err := EnsureAppDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cachePath := filepath.Join(appDir, legacyTidalAPICacheFile)
|
||||
if err := os.Remove(cachePath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func SanitizePersistedConfigSettings() error {
|
||||
configPath, err := GetConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var settings map[string]interface{}
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sanitized := SanitizeSettingsMap(settings)
|
||||
payload, err := json.MarshalIndent(sanitized, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(configPath, payload, 0o644)
|
||||
}
|
||||
|
||||
func GetDefaultMusicPath() string {
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
@@ -47,7 +174,7 @@ func LoadConfigSettings() (map[string]interface{}, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return settings, nil
|
||||
return SanitizeSettingsMap(settings), nil
|
||||
}
|
||||
|
||||
func GetRedownloadWithSuffixSetting() bool {
|
||||
@@ -66,13 +193,7 @@ func GetCustomTidalAPISetting() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
customAPI, _ := settings["customTidalApi"].(string)
|
||||
customAPI = strings.TrimRight(strings.TrimSpace(customAPI), "/")
|
||||
if strings.HasPrefix(customAPI, "https://") {
|
||||
return customAPI
|
||||
}
|
||||
|
||||
return ""
|
||||
return normalizeCustomTidalAPIValue(settings["customTidalApi"])
|
||||
}
|
||||
|
||||
func normalizeExistingFileCheckMode(value string) string {
|
||||
|
||||
@@ -1,21 +1,88 @@
|
||||
package backend
|
||||
|
||||
const amazonMusicAPIBaseURL = "https://amazon.spotbye.qzz.io"
|
||||
const qobuzMusicDLDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download"
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var defaultQobuzStreamAPIBaseURLs = []string{
|
||||
"https://dab.yeet.su/api/stream?trackId=",
|
||||
"https://dabmusic.xyz/api/stream?trackId=",
|
||||
const amazonMusicAPIBaseURL = "https://amazon.spotbye.qzz.io"
|
||||
|
||||
const (
|
||||
qobuzWJHEBaseURL = "https://music.wjhe.top"
|
||||
qobuzWJHESearchAPIURL = qobuzWJHEBaseURL + "/api/music/qobuz/search"
|
||||
qobuzWJHEStreamAPIURL = qobuzWJHEBaseURL + "/api/music/qobuz/url"
|
||||
qobuzMusicDLDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download"
|
||||
qobuzGDStudioAPIURLXYZ = "https://music.gdstudio.xyz/api.php"
|
||||
qobuzGDStudioAPIURLORG = "https://music.gdstudio.org/api.php"
|
||||
qobuzGDStudioVersion = "2026.5.10"
|
||||
)
|
||||
|
||||
var defaultQobuzDownloadProviderURLs = []string{
|
||||
qobuzWJHEStreamAPIURL,
|
||||
qobuzGDStudioAPIURLXYZ,
|
||||
qobuzGDStudioAPIURLORG,
|
||||
qobuzMusicDLDownloadAPIURL,
|
||||
}
|
||||
|
||||
func GetQobuzStreamAPIBaseURLs() []string {
|
||||
return append([]string(nil), defaultQobuzStreamAPIBaseURLs...)
|
||||
func GetQobuzDownloadProviderURLs() []string {
|
||||
return append([]string(nil), defaultQobuzDownloadProviderURLs...)
|
||||
}
|
||||
|
||||
func GetQobuzWJHESearchAPIURL() string {
|
||||
return qobuzWJHESearchAPIURL
|
||||
}
|
||||
|
||||
func GetQobuzWJHEStreamAPIURL() string {
|
||||
return qobuzWJHEStreamAPIURL
|
||||
}
|
||||
|
||||
func GetQobuzMusicDLDownloadAPIURL() string {
|
||||
return qobuzMusicDLDownloadAPIURL
|
||||
}
|
||||
|
||||
func GetQobuzGDStudioAPIURLs() []string {
|
||||
return []string{qobuzGDStudioAPIURLXYZ, qobuzGDStudioAPIURLORG}
|
||||
}
|
||||
|
||||
func GetQobuzGDStudioPrimaryAPIURL() string {
|
||||
return qobuzGDStudioAPIURLXYZ
|
||||
}
|
||||
|
||||
func GetQobuzGDStudioFallbackAPIURL() string {
|
||||
return qobuzGDStudioAPIURLORG
|
||||
}
|
||||
|
||||
func GetQobuzGDStudioSignatureHost(apiURL string) string {
|
||||
parsed, err := url.Parse(strings.TrimSpace(apiURL))
|
||||
if err != nil || strings.TrimSpace(parsed.Host) == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(parsed.Host)
|
||||
}
|
||||
|
||||
func GetQobuzGDStudioVersion() string {
|
||||
return qobuzGDStudioVersion
|
||||
}
|
||||
|
||||
func IsQobuzWJHEProviderURL(raw string) bool {
|
||||
candidate := strings.TrimSpace(raw)
|
||||
return candidate == qobuzWJHEStreamAPIURL || strings.HasPrefix(candidate, qobuzWJHEStreamAPIURL+"?")
|
||||
}
|
||||
|
||||
func IsQobuzMusicDLProviderURL(raw string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(raw), qobuzMusicDLDownloadAPIURL)
|
||||
}
|
||||
|
||||
func IsQobuzGDStudioProviderURL(raw string) bool {
|
||||
candidate := strings.TrimSpace(raw)
|
||||
for _, apiURL := range GetQobuzGDStudioAPIURLs() {
|
||||
if candidate == apiURL || strings.HasPrefix(candidate, apiURL+"?") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func GetAmazonMusicAPIBaseURL() string {
|
||||
return amazonMusicAPIBaseURL
|
||||
}
|
||||
|
||||
+475
-120
@@ -4,7 +4,9 @@ import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -20,17 +23,6 @@ import (
|
||||
|
||||
type QobuzDownloader struct {
|
||||
client *http.Client
|
||||
appID string
|
||||
}
|
||||
|
||||
type QobuzSearchResponse struct {
|
||||
Query string `json:"query"`
|
||||
Tracks struct {
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
Total int `json:"total"`
|
||||
Items []QobuzTrack `json:"items"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
type QobuzTrack struct {
|
||||
@@ -69,10 +61,6 @@ type QobuzTrack struct {
|
||||
} `json:"album"`
|
||||
}
|
||||
|
||||
type QobuzStreamResponse struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type qobuzMusicDLRequest struct {
|
||||
URL string `json:"url"`
|
||||
Quality string `json:"quality"`
|
||||
@@ -89,12 +77,20 @@ type qobuzMusicDLResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
const qobuzMusicDLProbeTrackID int64 = 341032040
|
||||
type qobuzPublicSearchResponse struct {
|
||||
Tracks struct {
|
||||
Total int `json:"total"`
|
||||
Items []QobuzTrack `json:"items"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
const qobuzProbeTrackID int64 = 341032040
|
||||
|
||||
var (
|
||||
qobuzMusicDLDebugKeyOnce sync.Once
|
||||
qobuzMusicDLDebugKey string
|
||||
qobuzMusicDLDebugKeyErr error
|
||||
qobuzStreamingURLPattern = regexp.MustCompile(`https?://[^\s"'<>\\)]+`)
|
||||
)
|
||||
|
||||
var qobuzMusicDLDebugKeySeedParts = [][]byte{
|
||||
@@ -129,7 +125,6 @@ func NewQobuzDownloader() *QobuzDownloader {
|
||||
client: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
},
|
||||
appID: qobuzDefaultAPIAppID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,112 +179,464 @@ func getQobuzMusicDLDebugKey() (string, error) {
|
||||
return qobuzMusicDLDebugKey, nil
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
|
||||
func firstNonEmptyQobuzValue(values ...string) string {
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeQobuzSearchValue(value string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"&", " and ",
|
||||
"feat.", " ",
|
||||
"ft.", " ",
|
||||
"/", " ",
|
||||
"-", " ",
|
||||
"_", " ",
|
||||
)
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
normalized = replacer.Replace(normalized)
|
||||
return strings.Join(strings.Fields(normalized), " ")
|
||||
}
|
||||
|
||||
func qobuzTrackDisplayArtist(track QobuzTrack) string {
|
||||
return firstNonEmptyQobuzValue(track.Performer.Name, track.Album.Artist.Name)
|
||||
}
|
||||
|
||||
func qobuzTrackSupportsHiRes(track QobuzTrack) bool {
|
||||
if track.Hires || track.HiresStreamable {
|
||||
return true
|
||||
}
|
||||
return track.MaximumBitDepth >= 24 || track.MaximumSamplingRate > 48
|
||||
}
|
||||
|
||||
func scoreQobuzSearchCandidate(track QobuzTrack, spotifyTrackName string, spotifyArtistName string, spotifyAlbumName string) int {
|
||||
score := 0
|
||||
|
||||
titleNeedle := normalizeQobuzSearchValue(spotifyTrackName)
|
||||
titleHaystack := normalizeQobuzSearchValue(track.Title)
|
||||
switch {
|
||||
case titleNeedle != "" && titleHaystack == titleNeedle:
|
||||
score += 1000
|
||||
case titleNeedle != "" && (strings.Contains(titleHaystack, titleNeedle) || strings.Contains(titleNeedle, titleHaystack)):
|
||||
score += 500
|
||||
}
|
||||
|
||||
artistNeedle := normalizeQobuzSearchValue(spotifyArtistName)
|
||||
artistHaystack := normalizeQobuzSearchValue(qobuzTrackDisplayArtist(track))
|
||||
switch {
|
||||
case artistNeedle != "" && artistHaystack == artistNeedle:
|
||||
score += 300
|
||||
case artistNeedle != "" && artistHaystack != "" && (strings.Contains(artistHaystack, artistNeedle) || strings.Contains(artistNeedle, artistHaystack)):
|
||||
score += 180
|
||||
}
|
||||
|
||||
albumNeedle := normalizeQobuzSearchValue(spotifyAlbumName)
|
||||
albumHaystack := normalizeQobuzSearchValue(track.Album.Title)
|
||||
switch {
|
||||
case albumNeedle != "" && albumHaystack == albumNeedle:
|
||||
score += 150
|
||||
case albumNeedle != "" && albumHaystack != "" && (strings.Contains(albumHaystack, albumNeedle) || strings.Contains(albumNeedle, albumHaystack)):
|
||||
score += 90
|
||||
}
|
||||
|
||||
if qobuzTrackSupportsHiRes(track) {
|
||||
score += 40
|
||||
} else if track.MaximumBitDepth >= 16 {
|
||||
score += 20
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
func mapQobuzWJHEQuality(quality string) (int, string) {
|
||||
switch strings.TrimSpace(quality) {
|
||||
case "27", "7":
|
||||
return 2000, "flac"
|
||||
case "", "6":
|
||||
return 1000, "flac"
|
||||
default:
|
||||
return 320, "mp3"
|
||||
}
|
||||
}
|
||||
|
||||
func buildQobuzWJHEDownloadURL(trackID int64, quality string) string {
|
||||
wjheQuality, wjheFormat := mapQobuzWJHEQuality(quality)
|
||||
params := url.Values{
|
||||
"ID": {strconv.FormatInt(trackID, 10)},
|
||||
"quality": {strconv.Itoa(wjheQuality)},
|
||||
"format": {wjheFormat},
|
||||
}
|
||||
return GetQobuzWJHEStreamAPIURL() + "?" + params.Encode()
|
||||
}
|
||||
|
||||
func qobuzURLLooksStreamable(raw string) bool {
|
||||
candidate := strings.TrimSpace(raw)
|
||||
if candidate == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(candidate)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != ""
|
||||
}
|
||||
|
||||
func findQobuzStreamingURLInPayload(payload interface{}) string {
|
||||
switch value := payload.(type) {
|
||||
case string:
|
||||
candidate := strings.ReplaceAll(strings.TrimSpace(value), `\/`, `/`)
|
||||
if qobuzURLLooksStreamable(candidate) {
|
||||
return candidate
|
||||
}
|
||||
case []interface{}:
|
||||
for _, item := range value {
|
||||
if url := findQobuzStreamingURLInPayload(item); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
case map[string]interface{}:
|
||||
for _, key := range []string{"download_url", "url", "play_url", "stream_url", "link", "file"} {
|
||||
if nested, ok := value[key]; ok {
|
||||
if url := findQobuzStreamingURLInPayload(nested); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, nested := range value {
|
||||
if url := findQobuzStreamingURLInPayload(nested); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractQobuzStreamingURL(body []byte) string {
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var directResp struct {
|
||||
URL string `json:"url"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
Data struct {
|
||||
URL string `json:"url"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &directResp); err == nil {
|
||||
for _, candidate := range []string{
|
||||
directResp.DownloadURL,
|
||||
directResp.URL,
|
||||
directResp.Data.DownloadURL,
|
||||
directResp.Data.URL,
|
||||
} {
|
||||
if qobuzURLLooksStreamable(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var genericPayload interface{}
|
||||
if err := json.Unmarshal(body, &genericPayload); err == nil {
|
||||
if streamURL := findQobuzStreamingURLInPayload(genericPayload); streamURL != "" {
|
||||
return streamURL
|
||||
}
|
||||
}
|
||||
|
||||
if openIdx := strings.Index(trimmed, "("); openIdx >= 0 {
|
||||
if closeIdx := strings.LastIndex(trimmed, ")"); closeIdx > openIdx+1 {
|
||||
callbackBody := strings.TrimSpace(trimmed[openIdx+1 : closeIdx])
|
||||
if streamURL := extractQobuzStreamingURL([]byte(callbackBody)); streamURL != "" {
|
||||
return streamURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, match := range qobuzStreamingURLPattern.FindAllString(trimmed, -1) {
|
||||
candidate := strings.ReplaceAll(match, `\/`, `/`)
|
||||
if qobuzURLLooksStreamable(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func newQobuzNoRedirectClient(base *http.Client) *http.Client {
|
||||
if base == nil {
|
||||
return &http.Client{
|
||||
Timeout: 20 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
cloned := *base
|
||||
if cloned.Timeout == 0 {
|
||||
cloned.Timeout = 20 * time.Second
|
||||
}
|
||||
cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) searchByISRC(isrc string, spotifyTrackName string, spotifyArtistName string, spotifyAlbumName string) (*QobuzTrack, error) {
|
||||
if strings.HasPrefix(isrc, "qobuz_") {
|
||||
trackID := strings.TrimPrefix(isrc, "qobuz_")
|
||||
trackID := strings.TrimSpace(strings.TrimPrefix(isrc, "qobuz_"))
|
||||
resp, err := doQobuzSignedRequest(http.MethodGet, "track/get", url.Values{"track_id": {trackID}}, q.client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch track: %w", err)
|
||||
return nil, fmt.Errorf("failed to fetch track from Qobuz public API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("Qobuz public API track/get returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
|
||||
}
|
||||
|
||||
var trackResp QobuzTrack
|
||||
if err := json.NewDecoder(resp.Body).Decode(&trackResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, fmt.Errorf("failed to decode Qobuz public track/get response: %w", err)
|
||||
}
|
||||
|
||||
return &trackResp, nil
|
||||
}
|
||||
|
||||
resp, err := doQobuzSignedRequest(http.MethodGet, "track/search", url.Values{
|
||||
"query": {isrc},
|
||||
"limit": {"1"},
|
||||
}, q.client)
|
||||
queries := []string{strings.TrimSpace(isrc)}
|
||||
if fallbackQuery := strings.TrimSpace(strings.Join([]string{spotifyTrackName, spotifyArtistName}, " ")); fallbackQuery != "" {
|
||||
queries = append(queries, fallbackQuery)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, query := range queries {
|
||||
if strings.TrimSpace(query) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var searchResp qobuzPublicSearchResponse
|
||||
if err := doQobuzSignedJSONRequest("track/search", url.Values{
|
||||
"query": {strings.TrimSpace(query)},
|
||||
"limit": {"10"},
|
||||
}, &searchResp); err != nil {
|
||||
lastErr = fmt.Errorf("failed to search Qobuz public API: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if searchResp.Tracks.Total == 0 || len(searchResp.Tracks.Items) == 0 {
|
||||
lastErr = fmt.Errorf("track not found for query: %s", query)
|
||||
continue
|
||||
}
|
||||
|
||||
bestIndex := 0
|
||||
bestScore := -1
|
||||
for idx, candidate := range searchResp.Tracks.Items {
|
||||
score := scoreQobuzSearchCandidate(candidate, spotifyTrackName, spotifyArtistName, spotifyAlbumName)
|
||||
if idx == 0 || score > bestScore {
|
||||
bestIndex = idx
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
|
||||
selected := searchResp.Tracks.Items[bestIndex]
|
||||
return &selected, nil
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("track not found for ISRC: %s", isrc)
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadFromWJHE(trackID int64, quality string) (string, error) {
|
||||
apiURL := buildQobuzWJHEDownloadURL(trackID, quality)
|
||||
client := newQobuzNoRedirectClient(q.client)
|
||||
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodHead, apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to search track: %w", err)
|
||||
return "", fmt.Errorf("failed to create WJHE request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to reach WJHE: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotImplemented {
|
||||
resp.Body.Close()
|
||||
req, err = NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create WJHE fallback request: %w", err)
|
||||
}
|
||||
resp, err = client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to reach WJHE with GET fallback: %w", err)
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
if location := strings.TrimSpace(resp.Header.Get("Location")); qobuzURLLooksStreamable(location) {
|
||||
return location, nil
|
||||
}
|
||||
|
||||
var searchResp QobuzSearchResponse
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 128*1024))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
return "", fmt.Errorf("failed to read WJHE response: %w", err)
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return nil, fmt.Errorf("API returned empty response")
|
||||
if streamURL := extractQobuzStreamingURL(body); streamURL != "" {
|
||||
return streamURL, nil
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &searchResp); err != nil {
|
||||
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
if streamURL := strings.TrimSpace(resp.Request.URL.String()); streamURL != "" && streamURL != apiURL && qobuzURLLooksStreamable(streamURL) {
|
||||
return streamURL, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
|
||||
}
|
||||
|
||||
if len(searchResp.Tracks.Items) == 0 {
|
||||
return nil, fmt.Errorf("track not found for ISRC: %s", isrc)
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
|
||||
return "", fmt.Errorf("WJHE returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
|
||||
}
|
||||
|
||||
return &searchResp.Tracks.Items[0], nil
|
||||
return "", fmt.Errorf("WJHE response did not include a stream URL")
|
||||
}
|
||||
|
||||
func buildQobuzAPIURL(apiBase string, trackID int64, quality string) string {
|
||||
return fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
|
||||
func qobuzGDStudioPaddedVersion() string {
|
||||
parts := strings.Split(GetQobuzGDStudioVersion(), ".")
|
||||
for idx, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if len(part) == 1 {
|
||||
part = "0" + part
|
||||
}
|
||||
parts[idx] = part
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, quality string) (string, error) {
|
||||
apiURL := buildQobuzAPIURL(apiBase, trackID, quality)
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
|
||||
func qobuzGDStudioEscapedValue(value string) string {
|
||||
return strings.ReplaceAll(url.QueryEscape(strings.TrimSpace(value)), "+", "%20")
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) getQobuzGDStudioTS9(apiURL string) string {
|
||||
fallback := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
if len(fallback) >= 9 {
|
||||
fallback = fallback[:9]
|
||||
}
|
||||
|
||||
client := q.client
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
|
||||
signatureHost := GetQobuzGDStudioSignatureHost(apiURL)
|
||||
if signatureHost == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, fmt.Sprintf("https://%s/time", signatureHost), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return fallback
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64))
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
timestamp := strings.TrimSpace(string(body))
|
||||
if len(timestamp) >= 9 {
|
||||
return timestamp[:9]
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
func buildQobuzGDStudioSignature(apiURL string, value string, ts9 string) string {
|
||||
signatureHost := GetQobuzGDStudioSignatureHost(apiURL)
|
||||
signatureBase := fmt.Sprintf("%s|%s|%s|%s", signatureHost, qobuzGDStudioPaddedVersion(), ts9, qobuzGDStudioEscapedValue(value))
|
||||
sum := md5.Sum([]byte(signatureBase))
|
||||
digest := hex.EncodeToString(sum[:])
|
||||
return strings.ToUpper(digest[len(digest)-8:])
|
||||
}
|
||||
|
||||
func mapQobuzGDStudioBitrate(quality string) string {
|
||||
switch strings.TrimSpace(quality) {
|
||||
case "27", "7":
|
||||
return "999"
|
||||
case "", "6":
|
||||
return "740"
|
||||
default:
|
||||
return "320"
|
||||
}
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadFromGDStudio(trackID int64, quality string, apiURL string) (string, error) {
|
||||
apiURL = strings.TrimSpace(apiURL)
|
||||
if apiURL == "" {
|
||||
apiURL = GetQobuzGDStudioPrimaryAPIURL()
|
||||
}
|
||||
|
||||
signatureHost := GetQobuzGDStudioSignatureHost(apiURL)
|
||||
if signatureHost == "" {
|
||||
return "", fmt.Errorf("GDStudio API URL is invalid: %s", apiURL)
|
||||
}
|
||||
|
||||
trackIDString := strconv.FormatInt(trackID, 10)
|
||||
ts9 := q.getQobuzGDStudioTS9(apiURL)
|
||||
payload := url.Values{
|
||||
"types": {"url"},
|
||||
"id": {trackIDString},
|
||||
"source": {"qobuz"},
|
||||
"br": {mapQobuzGDStudioBitrate(quality)},
|
||||
"s": {buildQobuzGDStudioSignature(apiURL, trackIDString, ts9)},
|
||||
}
|
||||
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodPost, apiURL, strings.NewReader(payload.Encode()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create GDStudio request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
req.Header.Set("Origin", fmt.Sprintf("https://%s", signatureHost))
|
||||
req.Header.Set("Referer", fmt.Sprintf("https://%s/", signatureHost))
|
||||
|
||||
resp, err := q.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("failed to reach GDStudio: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("failed to read GDStudio response: %w", err)
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return "", fmt.Errorf("empty body")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("GDStudio returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
|
||||
}
|
||||
|
||||
var streamResp QobuzStreamResponse
|
||||
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
|
||||
return streamResp.URL, nil
|
||||
streamURL := extractQobuzStreamingURL(body)
|
||||
if streamURL == "" {
|
||||
return "", fmt.Errorf("GDStudio response did not include a stream URL: %s", previewQobuzResponseBody(body, 256))
|
||||
}
|
||||
|
||||
var nestedResp struct {
|
||||
Data struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &nestedResp); err == nil && nestedResp.Data.URL != "" {
|
||||
return nestedResp.Data.URL, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("invalid response")
|
||||
return streamURL, nil
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadFromMusicDL(trackID int64, quality string) (string, error) {
|
||||
@@ -357,14 +704,46 @@ func (q *QobuzDownloader) DownloadFromMusicDL(trackID int64, quality string) (st
|
||||
return downloadURL, nil
|
||||
}
|
||||
|
||||
func CheckQobuzMusicDLStatus(client *http.Client) bool {
|
||||
func CheckQobuzMusicDLStatusDetailed(client *http.Client) error {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 4 * time.Second}
|
||||
}
|
||||
|
||||
downloader := &QobuzDownloader{client: client, appID: qobuzDefaultAPIAppID}
|
||||
_, err := downloader.DownloadFromMusicDL(qobuzMusicDLProbeTrackID, "27")
|
||||
return err == nil
|
||||
downloader := &QobuzDownloader{client: client}
|
||||
_, err := downloader.DownloadFromMusicDL(qobuzProbeTrackID, "27")
|
||||
return err
|
||||
}
|
||||
|
||||
func CheckQobuzMusicDLStatus(client *http.Client) bool {
|
||||
return CheckQobuzMusicDLStatusDetailed(client) == nil
|
||||
}
|
||||
|
||||
func CheckQobuzWJHEStatusDetailed(client *http.Client) error {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 4 * time.Second}
|
||||
}
|
||||
|
||||
downloader := &QobuzDownloader{client: client}
|
||||
_, err := downloader.DownloadFromWJHE(qobuzProbeTrackID, "27")
|
||||
return err
|
||||
}
|
||||
|
||||
func CheckQobuzWJHEStatus(client *http.Client) bool {
|
||||
return CheckQobuzWJHEStatusDetailed(client) == nil
|
||||
}
|
||||
|
||||
func CheckQobuzGDStudioAPIStatusDetailed(client *http.Client, apiURL string) error {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 4 * time.Second}
|
||||
}
|
||||
|
||||
downloader := &QobuzDownloader{client: client}
|
||||
_, err := downloader.DownloadFromGDStudio(qobuzProbeTrackID, "27", apiURL)
|
||||
return err
|
||||
}
|
||||
|
||||
func CheckQobuzGDStudioAPIStatus(client *http.Client, apiURL string) bool {
|
||||
return CheckQobuzGDStudioAPIStatusDetailed(client, apiURL) == nil
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFallback bool) (string, error) {
|
||||
@@ -376,65 +755,35 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
|
||||
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
|
||||
|
||||
downloadFunc := func(qual string) (string, error) {
|
||||
type Provider struct {
|
||||
Name string
|
||||
API string
|
||||
Func func() (string, error)
|
||||
}
|
||||
|
||||
providerMap := make(map[string]Provider)
|
||||
providerIDs := []string{GetQobuzMusicDLDownloadAPIURL()}
|
||||
|
||||
providerMap[GetQobuzMusicDLDownloadAPIURL()] = Provider{
|
||||
Name: "MusicDL",
|
||||
API: GetQobuzMusicDLDownloadAPIURL(),
|
||||
Func: func() (string, error) {
|
||||
return q.DownloadFromMusicDL(trackID, qual)
|
||||
},
|
||||
}
|
||||
|
||||
for _, api := range GetQobuzStreamAPIBaseURLs() {
|
||||
currentAPI := api
|
||||
providerIDs = append(providerIDs, currentAPI)
|
||||
providerMap[currentAPI] = Provider{
|
||||
Name: "Standard(" + currentAPI + ")",
|
||||
API: currentAPI,
|
||||
Func: func() (string, error) {
|
||||
return q.DownloadFromStandard(currentAPI, trackID, qual)
|
||||
},
|
||||
attemptMap := make(map[string]qobuzProviderAttempt)
|
||||
attemptIDs := make([]string, 0, len(GetQobuzDownloadProviderURLs()))
|
||||
for _, provider := range q.getQobuzDownloadProviders() {
|
||||
for _, attempt := range provider.Attempts(trackID, qual) {
|
||||
attemptMap[attempt.ID] = attempt
|
||||
attemptIDs = append(attemptIDs, attempt.ID)
|
||||
}
|
||||
}
|
||||
|
||||
orderedProviderIDs := prioritizeProviders("qobuz", providerIDs)
|
||||
primaryProviderID := GetQobuzMusicDLDownloadAPIURL()
|
||||
if len(orderedProviderIDs) > 1 && orderedProviderIDs[0] != primaryProviderID {
|
||||
reordered := []string{primaryProviderID}
|
||||
for _, providerID := range orderedProviderIDs {
|
||||
if providerID == primaryProviderID {
|
||||
continue
|
||||
}
|
||||
reordered = append(reordered, providerID)
|
||||
}
|
||||
orderedProviderIDs = reordered
|
||||
}
|
||||
orderedProviderIDs := prioritizeProviders("qobuz", attemptIDs)
|
||||
orderedProviderIDs = moveQobuzAttemptIDsLast(orderedProviderIDs, GetQobuzMusicDLDownloadAPIURL())
|
||||
var lastErr error
|
||||
for _, providerID := range orderedProviderIDs {
|
||||
p, ok := providerMap[providerID]
|
||||
attempt, ok := attemptMap[providerID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("Trying Provider: %s (Quality: %s)...\n", p.Name, qual)
|
||||
fmt.Printf("Trying Provider: %s (Quality: %s)...\n", attempt.Name, qual)
|
||||
|
||||
url, err := p.Func()
|
||||
url, err := attempt.Download()
|
||||
if err == nil {
|
||||
fmt.Printf("✓ Success\n")
|
||||
recordProviderSuccess("qobuz", p.API)
|
||||
recordProviderSuccess("qobuz", attempt.ID)
|
||||
return url, nil
|
||||
}
|
||||
|
||||
fmt.Printf("Provider failed: %v\n", err)
|
||||
recordProviderFailure("qobuz", p.API)
|
||||
recordProviderFailure("qobuz", attempt.ID)
|
||||
lastErr = err
|
||||
}
|
||||
return "", lastErr
|
||||
@@ -647,7 +996,7 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena
|
||||
}
|
||||
}
|
||||
|
||||
track, err := q.searchByISRC(isrc)
|
||||
track, err := q.searchByISRC(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -661,7 +1010,13 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena
|
||||
|
||||
qualityInfo := "Standard"
|
||||
if track.Hires {
|
||||
if track.MaximumBitDepth > 0 && track.MaximumSamplingRate > 0 {
|
||||
qualityInfo = fmt.Sprintf("Hi-Res (%d-bit / %.1f kHz)", track.MaximumBitDepth, track.MaximumSamplingRate)
|
||||
} else if track.MaximumBitDepth > 0 {
|
||||
qualityInfo = fmt.Sprintf("Hi-Res available (%d-bit)", track.MaximumBitDepth)
|
||||
} else {
|
||||
qualityInfo = "Hi-Res available"
|
||||
}
|
||||
}
|
||||
fmt.Printf("Quality: %s\n", qualityInfo)
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package backend
|
||||
|
||||
type qobuzDownloadProvider interface {
|
||||
Name() string
|
||||
Attempts(trackID int64, quality string) []qobuzProviderAttempt
|
||||
}
|
||||
|
||||
type qobuzProviderAttempt struct {
|
||||
Name string
|
||||
ID string
|
||||
Download func() (string, error)
|
||||
}
|
||||
|
||||
type QobuzProviderWJHE struct {
|
||||
downloader *QobuzDownloader
|
||||
}
|
||||
|
||||
func (p QobuzProviderWJHE) Name() string {
|
||||
return "QobuzProviderWJHE"
|
||||
}
|
||||
|
||||
func (p QobuzProviderWJHE) Attempts(trackID int64, quality string) []qobuzProviderAttempt {
|
||||
return []qobuzProviderAttempt{
|
||||
{
|
||||
Name: p.Name(),
|
||||
ID: GetQobuzWJHEStreamAPIURL(),
|
||||
Download: func() (string, error) {
|
||||
return p.downloader.DownloadFromWJHE(trackID, quality)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type QobuzProviderMusicDL struct {
|
||||
downloader *QobuzDownloader
|
||||
}
|
||||
|
||||
func (p QobuzProviderMusicDL) Name() string {
|
||||
return "QobuzProviderMusicDL"
|
||||
}
|
||||
|
||||
func (p QobuzProviderMusicDL) Attempts(trackID int64, quality string) []qobuzProviderAttempt {
|
||||
return []qobuzProviderAttempt{
|
||||
{
|
||||
Name: p.Name(),
|
||||
ID: GetQobuzMusicDLDownloadAPIURL(),
|
||||
Download: func() (string, error) {
|
||||
return p.downloader.DownloadFromMusicDL(trackID, quality)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type QobuzProviderGDStudio struct {
|
||||
downloader *QobuzDownloader
|
||||
}
|
||||
|
||||
func (p QobuzProviderGDStudio) Name() string {
|
||||
return "QobuzProviderGDStudio"
|
||||
}
|
||||
|
||||
func (p QobuzProviderGDStudio) Attempts(trackID int64, quality string) []qobuzProviderAttempt {
|
||||
attempts := make([]qobuzProviderAttempt, 0, len(GetQobuzGDStudioAPIURLs()))
|
||||
for _, apiURL := range GetQobuzGDStudioAPIURLs() {
|
||||
currentAPIURL := apiURL
|
||||
attempts = append(attempts, qobuzProviderAttempt{
|
||||
Name: p.Name(),
|
||||
ID: currentAPIURL,
|
||||
Download: func() (string, error) {
|
||||
return p.downloader.DownloadFromGDStudio(trackID, quality, currentAPIURL)
|
||||
},
|
||||
})
|
||||
}
|
||||
return attempts
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) getQobuzDownloadProviders() []qobuzDownloadProvider {
|
||||
return []qobuzDownloadProvider{
|
||||
QobuzProviderWJHE{downloader: q},
|
||||
QobuzProviderGDStudio{downloader: q},
|
||||
QobuzProviderMusicDL{downloader: q},
|
||||
}
|
||||
}
|
||||
|
||||
func moveQobuzAttemptIDsLast(providerIDs []string, lastIDs ...string) []string {
|
||||
if len(providerIDs) == 0 || len(lastIDs) == 0 {
|
||||
return append([]string(nil), providerIDs...)
|
||||
}
|
||||
|
||||
lastIDSet := make(map[string]struct{}, len(lastIDs))
|
||||
for _, providerID := range lastIDs {
|
||||
lastIDSet[providerID] = struct{}{}
|
||||
}
|
||||
|
||||
ordered := make([]string, 0, len(providerIDs))
|
||||
trailing := make([]string, 0, len(providerIDs))
|
||||
for _, providerID := range providerIDs {
|
||||
if _, ok := lastIDSet[providerID]; ok {
|
||||
trailing = append(trailing, providerID)
|
||||
continue
|
||||
}
|
||||
ordered = append(ordered, providerID)
|
||||
}
|
||||
|
||||
return append(ordered, trailing...)
|
||||
}
|
||||
+7
-46
@@ -50,28 +50,12 @@ type TidalBTSManifest struct {
|
||||
|
||||
func getConfiguredTidalAPIAttemptList() ([]string, error) {
|
||||
customAPI := GetCustomTidalAPISetting()
|
||||
apis, err := GetRotatedTidalAPIList()
|
||||
if customAPI == "" {
|
||||
return apis, err
|
||||
return nil, fmt.Errorf("no configured custom tidal api instance")
|
||||
}
|
||||
|
||||
if err != nil && len(apis) == 0 {
|
||||
return []string{customAPI}, nil
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(apis)+1)
|
||||
result = append(result, customAPI)
|
||||
for _, apiURL := range apis {
|
||||
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||
if apiURL == "" || apiURL == customAPI {
|
||||
continue
|
||||
}
|
||||
result = append(result, apiURL)
|
||||
}
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
func buildTidalOutputPath(outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyTrackNumber, spotifyDiscNumber int, isrcOverride string, useFirstArtistOnly bool) (string, bool, error) {
|
||||
if outputDir != "." {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
@@ -212,13 +196,6 @@ func finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName,
|
||||
|
||||
func NewTidalDownloader(apiURL string) *TidalDownloader {
|
||||
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||
if apiURL == "" {
|
||||
apis, err := getConfiguredTidalAPIAttemptList()
|
||||
if err == nil && len(apis) > 0 {
|
||||
apiURL = apis[0]
|
||||
}
|
||||
}
|
||||
|
||||
return &TidalDownloader{
|
||||
client: &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
@@ -275,6 +252,9 @@ func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
|
||||
|
||||
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
||||
fmt.Println("Fetching URL...")
|
||||
if strings.TrimSpace(t.apiURL) == "" {
|
||||
return "", fmt.Errorf("no configured custom tidal api instance")
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality)
|
||||
fmt.Printf("Tidal API URL: %s\n", url)
|
||||
@@ -606,11 +586,6 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
||||
cleanupTidalDownloadArtifacts(outputFilename)
|
||||
return outputFilename, err
|
||||
}
|
||||
if t.apiURL != "" {
|
||||
if err := RememberTidalAPIUsage(t.apiURL); err != nil {
|
||||
fmt.Printf("Warning: failed to persist last used Tidal API: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre)
|
||||
|
||||
@@ -662,11 +637,10 @@ func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameF
|
||||
return "", fmt.Errorf("songlink/songstats couldn't find Tidal URL: %w", err)
|
||||
}
|
||||
|
||||
if t.apiURL != "" {
|
||||
return t.DownloadByURL(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
|
||||
if t.apiURL == "" {
|
||||
return "", fmt.Errorf("no configured custom tidal api instance")
|
||||
}
|
||||
|
||||
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
|
||||
return t.DownloadByURL(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
|
||||
}
|
||||
|
||||
type SegmentTemplate struct {
|
||||
@@ -892,22 +866,9 @@ func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilena
|
||||
continue
|
||||
}
|
||||
|
||||
if err := RememberTidalAPIUsage(apiURL); err != nil {
|
||||
fmt.Printf("Warning: failed to persist last used Tidal API: %v\n", err)
|
||||
}
|
||||
|
||||
return apiURL, nil
|
||||
}
|
||||
|
||||
if !refreshed {
|
||||
if _, refreshErr := RefreshTidalAPIList(true); refreshErr != nil {
|
||||
errors = append(errors, fmt.Sprintf("gist refresh failed: %v", refreshErr))
|
||||
} else {
|
||||
fmt.Println("All cached Tidal APIs failed, refreshed gist list and retrying...")
|
||||
return t.tryDownloadAcrossTidalAPIs(trackID, outputFilename, quality, true)
|
||||
}
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("all tidal apis failed")
|
||||
}
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
tidalAPIListGistURL = "https://gist.githubusercontent.com/afkarxyz/2ce772b943321b9448b454f39403ce25/raw"
|
||||
tidalAPIListCacheFile = "tidal-api-urls.json"
|
||||
)
|
||||
|
||||
type tidalAPIListCache struct {
|
||||
URLs []string `json:"urls"`
|
||||
LastUsedURL string `json:"last_used_url,omitempty"`
|
||||
UpdatedAt int64 `json:"updated_at_unix"`
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
tidalAPIListMu sync.Mutex
|
||||
tidalAPIListState *tidalAPIListCache
|
||||
)
|
||||
|
||||
func loadTidalAPIListStateLocked() (*tidalAPIListCache, error) {
|
||||
if tidalAPIListState != nil {
|
||||
return cloneTidalAPIListState(tidalAPIListState), nil
|
||||
}
|
||||
|
||||
appDir, err := EnsureAppDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cachePath := filepath.Join(appDir, tidalAPIListCacheFile)
|
||||
data, err := os.ReadFile(cachePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
state := &tidalAPIListCache{}
|
||||
tidalAPIListState = cloneTidalAPIListState(state)
|
||||
return cloneTidalAPIListState(state), nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read tidal api cache: %w", err)
|
||||
}
|
||||
|
||||
var state tidalAPIListCache
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse tidal api cache: %w", err)
|
||||
}
|
||||
|
||||
state.URLs = normalizeTidalAPIURLs(state.URLs)
|
||||
|
||||
tidalAPIListState = cloneTidalAPIListState(&state)
|
||||
return cloneTidalAPIListState(&state), nil
|
||||
}
|
||||
|
||||
func saveTidalAPIListStateLocked(state *tidalAPIListCache) error {
|
||||
appDir, err := EnsureAppDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cachePath := filepath.Join(appDir, tidalAPIListCacheFile)
|
||||
payload, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode tidal api cache: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(cachePath, payload, 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write tidal api cache: %w", err)
|
||||
}
|
||||
|
||||
tidalAPIListState = cloneTidalAPIListState(state)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneTidalAPIListState(state *tidalAPIListCache) *tidalAPIListCache {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &tidalAPIListCache{
|
||||
URLs: append([]string(nil), state.URLs...),
|
||||
LastUsedURL: state.LastUsedURL,
|
||||
UpdatedAt: state.UpdatedAt,
|
||||
Source: state.Source,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeTidalAPIURLs(urls []string) []string {
|
||||
seen := make(map[string]struct{}, len(urls))
|
||||
normalized := make([]string, 0, len(urls))
|
||||
|
||||
for _, rawURL := range urls {
|
||||
url := strings.TrimRight(strings.TrimSpace(rawURL), "/")
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[url]; exists {
|
||||
continue
|
||||
}
|
||||
seen[url] = struct{}{}
|
||||
normalized = append(normalized, url)
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
func fetchTidalAPIURLsFromGist() ([]string, error) {
|
||||
client := &http.Client{Timeout: 12 * time.Second}
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, tidalAPIListGistURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create tidal api gist request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch tidal api gist: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
preview, _ := io.ReadAll(io.LimitReader(resp.Body, 200))
|
||||
return nil, fmt.Errorf("tidal api gist returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(preview)))
|
||||
}
|
||||
|
||||
var urls []string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&urls); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode tidal api gist: %w", err)
|
||||
}
|
||||
|
||||
urls = normalizeTidalAPIURLs(urls)
|
||||
if len(urls) == 0 {
|
||||
return nil, fmt.Errorf("tidal api gist returned no valid urls")
|
||||
}
|
||||
|
||||
return urls, nil
|
||||
}
|
||||
|
||||
func PrimeTidalAPIList() error {
|
||||
_, err := RefreshTidalAPIList(true)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to refresh Tidal API list from gist: %v\n", err)
|
||||
}
|
||||
|
||||
tidalAPIListMu.Lock()
|
||||
defer tidalAPIListMu.Unlock()
|
||||
|
||||
state, loadErr := loadTidalAPIListStateLocked()
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
|
||||
if len(state.URLs) == 0 {
|
||||
return fmt.Errorf("tidal api cache is empty")
|
||||
}
|
||||
|
||||
if state.UpdatedAt == 0 {
|
||||
state.UpdatedAt = time.Now().Unix()
|
||||
return saveTidalAPIListStateLocked(state)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func RefreshTidalAPIList(force bool) ([]string, error) {
|
||||
tidalAPIListMu.Lock()
|
||||
defer tidalAPIListMu.Unlock()
|
||||
|
||||
state, err := loadTidalAPIListStateLocked()
|
||||
if err != nil {
|
||||
state = &tidalAPIListCache{}
|
||||
}
|
||||
|
||||
if !force && len(state.URLs) > 0 {
|
||||
return append([]string(nil), state.URLs...), nil
|
||||
}
|
||||
|
||||
urls, fetchErr := fetchTidalAPIURLsFromGist()
|
||||
if fetchErr != nil {
|
||||
if len(state.URLs) > 0 {
|
||||
return append([]string(nil), state.URLs...), fetchErr
|
||||
}
|
||||
return nil, fetchErr
|
||||
}
|
||||
|
||||
state.URLs = urls
|
||||
state.UpdatedAt = time.Now().Unix()
|
||||
state.Source = "gist"
|
||||
|
||||
if !containsString(state.URLs, state.LastUsedURL) {
|
||||
state.LastUsedURL = ""
|
||||
}
|
||||
|
||||
if err := saveTidalAPIListStateLocked(state); err != nil {
|
||||
return append([]string(nil), state.URLs...), err
|
||||
}
|
||||
|
||||
return append([]string(nil), state.URLs...), nil
|
||||
}
|
||||
|
||||
func GetTidalAPIList() ([]string, error) {
|
||||
tidalAPIListMu.Lock()
|
||||
defer tidalAPIListMu.Unlock()
|
||||
|
||||
state, err := loadTidalAPIListStateLocked()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(state.URLs) == 0 {
|
||||
return nil, fmt.Errorf("no cached tidal api urls")
|
||||
}
|
||||
|
||||
return append([]string(nil), state.URLs...), nil
|
||||
}
|
||||
|
||||
func GetRotatedTidalAPIList() ([]string, error) {
|
||||
tidalAPIListMu.Lock()
|
||||
defer tidalAPIListMu.Unlock()
|
||||
|
||||
state, err := loadTidalAPIListStateLocked()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
urls := state.URLs
|
||||
if len(urls) == 0 {
|
||||
return nil, fmt.Errorf("no cached tidal api urls")
|
||||
}
|
||||
|
||||
return rotateTidalAPIURLs(urls, state.LastUsedURL), nil
|
||||
}
|
||||
|
||||
func RememberTidalAPIUsage(apiURL string) error {
|
||||
tidalAPIListMu.Lock()
|
||||
defer tidalAPIListMu.Unlock()
|
||||
|
||||
state, err := loadTidalAPIListStateLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
state.LastUsedURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||
if state.UpdatedAt == 0 {
|
||||
state.UpdatedAt = time.Now().Unix()
|
||||
}
|
||||
|
||||
return saveTidalAPIListStateLocked(state)
|
||||
}
|
||||
|
||||
func rotateTidalAPIURLs(urls []string, lastUsedURL string) []string {
|
||||
normalized := normalizeTidalAPIURLs(urls)
|
||||
if len(normalized) < 2 {
|
||||
return normalized
|
||||
}
|
||||
|
||||
lastUsedURL = strings.TrimRight(strings.TrimSpace(lastUsedURL), "/")
|
||||
if lastUsedURL == "" {
|
||||
return normalized
|
||||
}
|
||||
|
||||
lastIndex := -1
|
||||
for idx, candidate := range normalized {
|
||||
if candidate == lastUsedURL {
|
||||
lastIndex = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if lastIndex == -1 {
|
||||
return normalized
|
||||
}
|
||||
|
||||
rotated := make([]string, 0, len(normalized))
|
||||
rotated = append(rotated, normalized[lastIndex+1:]...)
|
||||
rotated = append(rotated, normalized[:lastIndex+1]...)
|
||||
return rotated
|
||||
}
|
||||
|
||||
func containsString(values []string, target string) bool {
|
||||
target = strings.TrimRight(strings.TrimSpace(target), "/")
|
||||
for _, value := range values {
|
||||
if strings.TrimRight(strings.TrimSpace(value), "/") == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -24,15 +24,16 @@ import { AudioResamplerPage } from "@/components/AudioResamplerPage";
|
||||
import { FileManagerPage } from "@/components/FileManagerPage";
|
||||
import { SettingsPage } from "@/components/SettingsPage";
|
||||
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
|
||||
import { AboutPage } from "@/components/AboutPage";
|
||||
import { OtherProjects } from "@/components/OtherProjects";
|
||||
import { HistoryPage } from "@/components/HistoryPage";
|
||||
import { SupportPage } from "@/components/SupportPage";
|
||||
import type { HistoryItem } from "@/components/FetchHistory";
|
||||
import { useDownload } from "@/hooks/useDownload";
|
||||
import { useMetadata } from "@/hooks/useMetadata";
|
||||
import { useLyrics } from "@/hooks/useLyrics";
|
||||
import { useCover } from "@/hooks/useCover";
|
||||
import { useAvailability } from "@/hooks/useAvailability";
|
||||
import { ensureSpotiFLACNextStatusCheckStarted } from "@/lib/api-status";
|
||||
import { ensureApiStatusCheckStarted } from "@/lib/api-status";
|
||||
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
|
||||
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
||||
import { buildPlaylistFolderName } from "@/lib/playlist";
|
||||
@@ -198,7 +199,7 @@ function App() {
|
||||
};
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
checkForUpdates();
|
||||
ensureSpotiFLACNextStatusCheckStarted();
|
||||
ensureApiStatusCheckStarted();
|
||||
void loadHistory();
|
||||
return () => {
|
||||
mediaQuery.removeEventListener("change", handleChange);
|
||||
@@ -528,8 +529,10 @@ function App() {
|
||||
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn}/>;
|
||||
case "debug":
|
||||
return <DebugLoggerPage />;
|
||||
case "about":
|
||||
return <AboutPage />;
|
||||
case "projects":
|
||||
return <OtherProjects />;
|
||||
case "support":
|
||||
return <SupportPage />;
|
||||
case "history":
|
||||
return <HistoryPage onHistorySelect={(cachedData) => {
|
||||
metadata.loadFromCache(cachedData);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 749.3 227.1" fill="#FFFFFF">
|
||||
<!-- Generator: Adobe Illustrator 29.3.1, SVG Export Plug-In . SVG Version: 2.1.0 Build 151) -->
|
||||
<path d="M222.8,85.4c0-3.4,2.5-5.6,6.4-5.6h18.6c16.9,0,28.3,9.3,28.3,22.9s-11.3,23.3-28.3,23.3h-2.6c-6.5,0-9.8,3.4-9.8,8.8v15.2c0,4.3-2.5,7-6.3,7s-6.3-2.7-6.3-7v-64.6ZM235.4,104.7c0,6.8,3.5,10.1,10.1,10.1h1.6c9.3,0,16.1-3.8,16.1-12.1s-6.8-12.1-16.1-12.1h-1.6c-6.6,0-10.1,3.2-10.1,10.1v4.1ZM276.1,151.1c0,3.6,2.5,5.9,6.3,5.9s4.8-1.6,6.1-5l2.3-6.1c1.8-4.9,5.1-7.1,8.6-7.1h20.5c3.6,0,6.8,2.3,8.6,7.1l2.3,6.1c1.3,3.4,3.6,5,6.1,5,3.8,0,6.3-2.4,6.3-5.9s-.2-2.2-.6-3.4l-24.5-63.8c-1.5-3.9-5-5.8-8.3-5.8s-6.8,1.9-8.3,5.8l-24.5,63.8c-.4,1.2-.6,2.4-.6,3.4ZM300,122.1c0-1.2.3-2.3.9-3.9l4.6-12.9c.9-2.5,2.4-3.7,4.1-3.7s3.2,1.2,4.1,3.7l4.6,12.9c.5,1.6.9,2.7.9,3.9,0,3.2-1.8,5.5-6.7,5.5h-5.8c-4.9,0-6.7-2.3-6.7-5.5ZM339,85.6c0-3.5,2.5-5.8,6.5-5.8h49.7c4,0,6.5,2.4,6.5,5.8s-2.5,5.8-6.5,5.8h-8.3c-6.6,0-10.2,3.4-10.2,11v47.4c0,4.4-2.5,7.1-6.4,7.1s-6.4-2.7-6.4-7.1v-47.4c0-7.7-3.6-11-10.2-11h-8.3c-4,0-6.5-2.4-6.5-5.8ZM413.4,150c0,4.3,2.5,7,6.3,7s6.3-2.7,6.3-7v-17.2c0-4.9,2.8-6.9,6.3-6.9h.9c2.3,0,4.5,1.4,5.9,3.5l16.4,24.1c1.5,2.3,3.5,3.6,5.9,3.6s5.8-2.7,5.8-5.9-.4-2.7-1.4-4.1l-10.9-15.3c-1.3-1.8-1.8-3.4-1.8-4.6,0-2.7,2.4-4.6,5.2-6.7,5.1-3.8,10.6-8.8,10.6-18.3s-10.4-22.3-27.5-22.3h-21.7c-3.9,0-6.3,2.3-6.3,5.6v64.6ZM425.9,103.7v-3.2c0-7,3.7-9.9,9.3-9.9h5.4c9.3,0,15.2,3.5,15.2,11.5s-6.3,11.7-15.6,11.7h-5.1c-5.6,0-9.3-2.9-9.3-9.9ZM484.8,149.8v-64.4c0-3.4,2.4-5.6,6.3-5.6h40.9c3.9,0,6.3,2.3,6.3,5.6s-2.4,5.6-6.3,5.6h-25.8c-5.1,0-8.8,3-8.8,8.8v2.4c0,5.7,3.7,8.8,8.8,8.8h20c3.9,0,6.3,2.3,6.3,5.6s-2.4,5.6-6.3,5.6h-19.2c-5.1,0-9.5,3.1-9.5,9.5v3c0,6.4,4.4,9.5,9.5,9.5h25.1c3.9,0,6.3,2.3,6.3,5.6s-2.4,5.6-6.3,5.6h-40.9c-3.9,0-6.3-2.3-6.3-5.6ZM545.7,117.6c0-23.3,17.5-39.4,38-39.4s38,16.1,38,39.4-17.5,39.4-38,39.4-38-16.1-38-39.4ZM559.9,117.6c0,16.4,9.7,26.9,23.8,26.9s23.8-10.5,23.8-26.9-9.7-26.9-23.8-26.9-23.8,10.4-23.8,26.9ZM636.8,150c0,4.3,2.5,7,6.3,7s6.3-2.7,6.3-7v-33.1c0-4,2.4-5.9,4.9-5.9s3.6,1.1,4.8,3l20.8,34.7c2.8,4.8,5.4,8.3,10.7,8.3s8.8-3.7,8.8-9.6v-62.2c0-4.3-2.5-7-6.3-7s-6.3,2.7-6.3,7v33.1c0,4-2.4,5.9-4.9,5.9s-3.6-1.1-4.8-3l-20.8-34.7c-2.8-4.8-5.4-8.3-10.7-8.3s-8.8,3.7-8.8,9.6v62.2Z"/>
|
||||
<path d="M169.2,87.5c0-16.7-13-30.3-28.2-35.2-18.9-6.1-43.8-5.2-61.9,3.3-21.9,10.3-28.7,32.9-29,55.5-.2,18.5,1.6,67.4,29.2,67.7,20.5.3,23.5-26.1,33-38.8,6.7-9,15.4-11.6,26.1-14.2,18.4-4.5,30.9-19,30.8-38.2Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 27.4.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 1080 1080" style="enable-background:new 0 0 1080 1080;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#FFFFFF;}
|
||||
</style>
|
||||
<path class="st0" d="M1033.05,324.45c-0.19-137.9-107.59-250.92-233.6-291.7c-156.48-50.64-362.86-43.3-512.28,27.2
|
||||
C106.07,145.41,49.18,332.61,47.06,519.31c-1.74,153.5,13.58,557.79,241.62,560.67c169.44,2.15,194.67-216.18,273.07-321.33
|
||||
c55.78-74.81,127.6-95.94,216.01-117.82C929.71,603.22,1033.27,483.3,1033.05,324.45z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 735 B |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,14 +1,14 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SearchCheck, CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon, MusicBrainzIcon, AppleMusicIcon, DeezerIcon } from "./PlatformIcons";
|
||||
import { PlugZap, CheckCircle2, Loader2, Wrench } from "lucide-react";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon, AppleMusicIcon, DeezerIcon } from "./PlatformIcons";
|
||||
import { useApiStatus } from "@/hooks/useApiStatus";
|
||||
import { SPOTIFLAC_NEXT_SOURCES } from "@/lib/api-status";
|
||||
function renderStatusIcon(status: "checking" | "online" | "offline" | "idle") {
|
||||
function renderStatusIndicator(status: "checking" | "online" | "offline" | "idle") {
|
||||
if (status === "online") {
|
||||
return <CheckCircle2 className="h-5 w-5 text-emerald-500"/>;
|
||||
}
|
||||
if (status === "offline") {
|
||||
return <XCircle className="h-5 w-5 text-destructive"/>;
|
||||
return <Wrench className="h-4 w-4 text-amber-600 dark:text-amber-400"/>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -19,9 +19,6 @@ function renderPlatformIcon(type: string) {
|
||||
if (type === "amazon") {
|
||||
return <AmazonIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
||||
}
|
||||
if (type === "musicbrainz") {
|
||||
return <MusicBrainzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
||||
}
|
||||
if (type === "deezer") {
|
||||
return <DeezerIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
||||
}
|
||||
@@ -31,27 +28,30 @@ function renderPlatformIcon(type: string) {
|
||||
return <QobuzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
||||
}
|
||||
export function ApiStatusTab() {
|
||||
const { sources, statuses, nextStatuses, checkingSources, checkOne } = useApiStatus();
|
||||
const { sources, statuses, nextStatuses, checkingSources, checkAllCurrent, checkAllNext } = useApiStatus();
|
||||
const isCheckingCurrent = sources.some((source) => checkingSources[source.id] === true);
|
||||
const isCheckingNext = SPOTIFLAC_NEXT_SOURCES.some((source) => nextStatuses[source.id] === "checking");
|
||||
return (<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Services</h3>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC</h3>
|
||||
<Button variant="outline" size="sm" onClick={() => void checkAllCurrent()} disabled={isCheckingCurrent} className="gap-2">
|
||||
{isCheckingCurrent ? <Loader2 className="h-4 w-4 animate-spin"/> : <PlugZap className="h-4 w-4"/>}
|
||||
Check
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{sources.map((source) => {
|
||||
const status = statuses[source.id] || "idle";
|
||||
const isChecking = checkingSources[source.id] === true;
|
||||
return (<div key={source.id} className="space-y-4 p-4 border rounded-lg bg-card text-card-foreground shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{renderPlatformIcon(source.type)}
|
||||
<p className="font-medium leading-none">{source.name}</p>
|
||||
</div>
|
||||
<div className="flex items-center">{renderStatusIcon(status)}</div>
|
||||
<div className="flex items-center">{renderStatusIndicator(status)}</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => void checkOne(source.id)} disabled={isChecking} className="w-full gap-2">
|
||||
{isChecking ? <Loader2 className="h-4 w-4 animate-spin"/> : <SearchCheck className="h-4 w-4"/>}
|
||||
Check
|
||||
</Button>
|
||||
</div>);
|
||||
})}
|
||||
</div>
|
||||
@@ -60,7 +60,13 @@ export function ApiStatusTab() {
|
||||
<div className="border-t"/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Next Services</h3>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Next</h3>
|
||||
<Button variant="outline" size="sm" onClick={() => void checkAllNext()} disabled={isCheckingNext} className="gap-2">
|
||||
{isCheckingNext ? <Loader2 className="h-4 w-4 animate-spin"/> : <PlugZap className="h-4 w-4"/>}
|
||||
Check
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{SPOTIFLAC_NEXT_SOURCES.map((source) => {
|
||||
@@ -70,7 +76,7 @@ export function ApiStatusTab() {
|
||||
{renderPlatformIcon(source.id)}
|
||||
<p className="font-medium leading-none">{source.name}</p>
|
||||
</div>
|
||||
<div className="flex items-center">{renderStatusIcon(status)}</div>
|
||||
<div className="flex items-center">{renderStatusIndicator(status)}</div>
|
||||
</div>);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useEffect, useState } from "react";
|
||||
import { openExternal } from "@/lib/utils";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
|
||||
import { Star, GitFork, Clock, Download, Blocks, Heart, Copy, CircleCheck, Info } from "lucide-react";
|
||||
import { Star, GitFork, Clock, Download, Info } from "lucide-react";
|
||||
import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
|
||||
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
|
||||
import XIcon from "@/assets/x.webp";
|
||||
import XProIcon from "@/assets/x-pro.webp";
|
||||
import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
|
||||
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
|
||||
import SpotiFLACNextIcon from "@/assets/icons/next.svg";
|
||||
import KofiLogo from "@/assets/ko-fi.gif";
|
||||
import KofiSvg from "@/assets/kofi_symbol.svg";
|
||||
import UsdtBarcode from "@/assets/usdt.jpg";
|
||||
import { langColors } from "@/assets/github-lang-colors";
|
||||
const browserExtensionItems = [
|
||||
{ icon: AudioTTSProIcon, label: "AudioTTS Pro", alt: "AudioTTS Pro" },
|
||||
{ icon: ChatGPTTTSIcon, label: "ChatGPT TTS", alt: "ChatGPT TTS" },
|
||||
{ icon: XIcon, label: "Twitter/X Media Batch Downloader", alt: "Twitter/X Media Batch Downloader" },
|
||||
{ icon: XProIcon, label: "Twitter/X Media Batch Downloader Pro", alt: "Twitter/X Media Batch Downloader Pro" },
|
||||
];
|
||||
const projectCardClass = "cursor-pointer gap-3 py-5 transition-colors hover:bg-muted/50 dark:hover:bg-accent/50";
|
||||
const projectCardHeaderClass = "px-5 gap-1.5";
|
||||
@@ -26,10 +20,8 @@ const projectCardContentClass = "px-5";
|
||||
const projectBodyClass = "text-[13px] leading-snug";
|
||||
const releaseMetaClass = "text-xs text-muted-foreground whitespace-nowrap";
|
||||
const releaseVersionClass = "text-xs bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold whitespace-nowrap";
|
||||
export function AboutPage() {
|
||||
const [activeTab, setActiveTab] = useState<"projects" | "support">("projects");
|
||||
export function OtherProjects() {
|
||||
const [repoStats, setRepoStats] = useState<Record<string, any>>({});
|
||||
const [copiedUsdt, setCopiedUsdt] = useState(false);
|
||||
useEffect(() => {
|
||||
const fetchRepoStats = async () => {
|
||||
const CACHE_KEY = "github_repo_stats_v4";
|
||||
@@ -181,24 +173,10 @@ export function AboutPage() {
|
||||
};
|
||||
return (<div className="flex flex-col space-y-3">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h2 className="text-2xl font-bold tracking-tight">About</h2>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Other Projects</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 border-b shrink-0">
|
||||
<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>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
|
||||
|
||||
{activeTab === "projects" && (<div className="pr-1.5">
|
||||
<div className="flex-1 min-h-0 pr-1.5">
|
||||
<div className="grid gap-2 grid-cols-3">
|
||||
<Card className={projectCardClass} onClick={() => openExternal("https://github.com/spotbye/SpotiFLAC-Next")}>
|
||||
<CardHeader className={projectCardHeaderClass}>
|
||||
@@ -249,7 +227,7 @@ export function AboutPage() {
|
||||
Note
|
||||
</div>
|
||||
<p className="text-xs leading-snug text-sky-700 dark:text-sky-300">
|
||||
This project released as a token of appreciation for those who have supported SpotiFLAC on Ko-fi. It’s not a paid product, but it’s shared privately through a supporter-only post.
|
||||
This project released as a token of appreciation for those who have supported SpotiFLAC through Ko-fi, Patreon, or crypto. It’s not a paid product, but it’s shared privately through a supporter-only post.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>)}
|
||||
@@ -313,7 +291,7 @@ export function AboutPage() {
|
||||
</CardContent>)}
|
||||
</Card>
|
||||
<div className="flex h-full flex-col gap-1.5">
|
||||
<Card className={`${projectCardClass} flex-1`} onClick={() => openExternal("https://exyezed.qzz.io/")}>
|
||||
<Card className={`${projectCardClass} flex-1`} onClick={() => openExternal("https://exyezed.fyi/")}>
|
||||
<CardHeader className={projectCardHeaderClass}>
|
||||
<CardTitle className="leading-tight">Browser Extensions & Scripts</CardTitle>
|
||||
<CardDescription className="flex flex-col gap-2.5 pt-1.5">
|
||||
@@ -339,55 +317,6 @@ export function AboutPage() {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{activeTab === "support" && (<div className="flex flex-col items-center justify-center p-4 space-y-6">
|
||||
<div className="flex flex-col md:flex-row w-full max-w-3xl bg-card rounded-xl border shadow-sm">
|
||||
|
||||
<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">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="h-32 flex items-center justify-center w-full relative">
|
||||
<img src={KofiLogo} className="w-72 absolute pointer-events-none" alt="Ko-fi"/>
|
||||
</div>
|
||||
<h4 className="font-semibold text-foreground">Support via Ko-fi</h4>
|
||||
<p className="text-sm text-muted-foreground text-center px-4">
|
||||
Enjoying the project? You can support ongoing development by buying me a coffee.
|
||||
</p>
|
||||
</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 me on Ko-fi
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex-1 p-6 flex flex-col items-center justify-between space-y-6">
|
||||
<div className="flex flex-col items-center space-y-4 w-full">
|
||||
<div className="h-32 flex items-center justify-center">
|
||||
<div className="p-2 bg-white rounded-xl shadow-sm border">
|
||||
<img src={UsdtBarcode} className="w-24 h-24 object-contain" alt="USDT Barcode"/>
|
||||
</div>
|
||||
</div>
|
||||
<h4 className="font-semibold text-foreground">USDT (TRC20)</h4>
|
||||
<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 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">
|
||||
<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>);
|
||||
}
|
||||
@@ -6,10 +6,10 @@ import { InputWithContext } from "@/components/ui/input-with-context";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import { FolderOpen, Save, RotateCcw, Info, ArrowRight, MonitorCog, FolderCog, Router, FolderLock, Plus, Trash2, ExternalLink } from "lucide-react";
|
||||
import { FolderOpen, Save, RotateCcw, Info, ArrowRight, MonitorCog, FolderCog, Router, FolderLock, Plus, Trash2, ExternalLink, PlugZap, Download, Tags } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, getFontOptions, parseGoogleFontUrl, loadGoogleFontUrl, loadCustomFonts, saveCustomFonts, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type CustomFontFamily, type FolderPreset, type FilenamePreset, type ExistingFileCheckMode, } from "@/lib/settings";
|
||||
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, getFontOptions, parseGoogleFontUrl, loadGoogleFontUrl, loadCustomFonts, saveCustomFonts, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, hasConfiguredCustomTidalApi, sanitizeAutoOrder, type Settings as SettingsType, type FontFamily, type CustomFontFamily, type FolderPreset, type FilenamePreset, type ExistingFileCheckMode, } from "@/lib/settings";
|
||||
import { themes, applyTheme } from "@/lib/themes";
|
||||
import { SelectFolder, OpenConfigFolder, CheckCustomTidalAPI } from "../../wailsjs/go/main/App";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
@@ -33,6 +33,11 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
const parsedAddFont = parseGoogleFontUrl(addFontUrl);
|
||||
const fontOptions = getFontOptions(tempSettings.customFonts);
|
||||
const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
|
||||
const hasCustomTidalInstanceConfigured = hasConfiguredCustomTidalApi(tempSettings.customTidalApi);
|
||||
const effectiveDownloader = !hasCustomTidalInstanceConfigured && tempSettings.downloader === "tidal"
|
||||
? "auto"
|
||||
: tempSettings.downloader;
|
||||
const effectiveAutoOrder = sanitizeAutoOrder(tempSettings.autoOrder, hasCustomTidalInstanceConfigured);
|
||||
const resetToSaved = useCallback(() => {
|
||||
const freshSavedSettings = getSettings();
|
||||
flushSync(() => {
|
||||
@@ -96,7 +101,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
}, []);
|
||||
const handleSave = async () => {
|
||||
await saveSettings(tempSettings);
|
||||
setSavedSettings(tempSettings);
|
||||
const persistedSettings = getSettings();
|
||||
setSavedSettings(persistedSettings);
|
||||
setTempSettings(persistedSettings);
|
||||
toast.success("Settings saved");
|
||||
onUnsavedChangesChange?.(false);
|
||||
};
|
||||
@@ -184,13 +191,15 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
customTidalApi: normalizedValue,
|
||||
};
|
||||
await saveSettings(nextSavedSettings);
|
||||
setSavedSettings((prev) => ({
|
||||
...prev,
|
||||
customTidalApi: normalizedValue,
|
||||
}));
|
||||
const nextSavedState = getSettings();
|
||||
setSavedSettings(nextSavedState);
|
||||
setTempSettings((prev) => ({
|
||||
...prev,
|
||||
customTidalApi: normalizedValue,
|
||||
customTidalApi: nextSavedState.customTidalApi,
|
||||
downloader: !hasConfiguredCustomTidalApi(nextSavedState.customTidalApi) && prev.downloader === "tidal"
|
||||
? nextSavedState.downloader
|
||||
: prev.downloader,
|
||||
autoOrder: sanitizeAutoOrder(prev.autoOrder, hasConfiguredCustomTidalApi(nextSavedState.customTidalApi)),
|
||||
}));
|
||||
}, []);
|
||||
const handleCheckCustomTidalApi = async () => {
|
||||
@@ -216,7 +225,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
toast.error(`Failed to check HiFi API instance: ${error}`);
|
||||
}
|
||||
};
|
||||
const [activeTab, setActiveTab] = useState<"general" | "files" | "api">("general");
|
||||
const [activeTab, setActiveTab] = useState<"general" | "download" | "files" | "metadata" | "status">("general");
|
||||
return (<div className="space-y-4 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
@@ -248,33 +257,27 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
<MonitorCog className="h-4 w-4"/>
|
||||
General
|
||||
</Button>
|
||||
<Button variant={activeTab === "download" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("download")} className="rounded-b-none gap-2">
|
||||
<Download className="h-4 w-4"/>
|
||||
Download
|
||||
</Button>
|
||||
<Button variant={activeTab === "files" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("files")} className="rounded-b-none gap-2">
|
||||
<FolderCog className="h-4 w-4"/>
|
||||
File Management
|
||||
Files
|
||||
</Button>
|
||||
<Button variant={activeTab === "api" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("api")} className="rounded-b-none gap-2">
|
||||
<Button variant={activeTab === "metadata" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("metadata")} className="rounded-b-none gap-2">
|
||||
<Tags className="h-4 w-4"/>
|
||||
Metadata
|
||||
</Button>
|
||||
<Button variant={activeTab === "status" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("status")} className="rounded-b-none gap-2">
|
||||
<Router className="h-4 w-4"/>
|
||||
Status
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pt-4">
|
||||
{activeTab === "general" && (<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{activeTab === "general" && (<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="download-path">Download Path</Label>
|
||||
<div className="flex gap-2">
|
||||
<InputWithContext id="download-path" value={tempSettings.downloadPath} onChange={(e) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
downloadPath: e.target.value,
|
||||
}))} placeholder="C:\Users\YourUsername\Music"/>
|
||||
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="theme-mode">Mode</Label>
|
||||
<Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}>
|
||||
@@ -309,7 +312,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="font">Font</Label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -357,50 +362,27 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{activeTab === "download" && (<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="link-resolver">Link Resolver</Label>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Select value={tempSettings.linkResolver} onValueChange={(value: "songstats" | "songlink") => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
linkResolver: value,
|
||||
}))}>
|
||||
<SelectTrigger id="link-resolver" className="h-9 w-fit min-w-35">
|
||||
<SelectValue placeholder="Select a link resolver"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="songlink">
|
||||
<span className="flex items-center gap-2">
|
||||
<SonglinkIcon className="h-4 w-4 shrink-0"/>
|
||||
Songlink
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="songstats">
|
||||
<span className="flex items-center gap-2">
|
||||
<SongstatsIcon className="h-4 w-4 shrink-0"/>
|
||||
Songstats
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="allow-link-resolver-fallback" checked={tempSettings.allowResolverFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
allowResolverFallback: checked,
|
||||
}))}/>
|
||||
<Label htmlFor="allow-link-resolver-fallback" className="text-sm font-normal cursor-pointer">
|
||||
Allow Fallback
|
||||
</Label>
|
||||
</div>
|
||||
<Label>Tidal Source</Label>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button type="button" variant="outline" onClick={() => setShowCustomTidalApiDialog(true)} className="gap-2">
|
||||
<TidalIcon />
|
||||
Add Instance
|
||||
</Button>
|
||||
{tempSettings.customTidalApi && (<span className="max-w-[260px] truncate text-xs text-muted-foreground" title={tempSettings.customTidalApi}>
|
||||
{tempSettings.customTidalApi}
|
||||
</span>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="downloader">Source</Label>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Select value={tempSettings.downloader} onValueChange={(value: SettingsType["downloader"]) => setTempSettings((prev) => ({
|
||||
<Select value={effectiveDownloader} onValueChange={(value: SettingsType["downloader"]) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
downloader: value,
|
||||
}))}>
|
||||
@@ -409,12 +391,12 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="tidal">
|
||||
{hasCustomTidalInstanceConfigured && (<SelectItem value="tidal">
|
||||
<span className="flex items-center gap-2">
|
||||
<TidalIcon />
|
||||
Tidal
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectItem>)}
|
||||
<SelectItem value="qobuz">
|
||||
<span className="flex items-center gap-2">
|
||||
<QobuzIcon />
|
||||
@@ -427,20 +409,19 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
Amazon Music
|
||||
</span>
|
||||
</SelectItem>
|
||||
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{tempSettings.downloader === "auto" && (<>
|
||||
<Select value={tempSettings.autoOrder || "tidal-qobuz-amazon"} onValueChange={(value: string) => setTempSettings((prev) => ({
|
||||
{effectiveDownloader === "auto" && (<>
|
||||
<Select value={effectiveAutoOrder} onValueChange={(value: string) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
autoOrder: value,
|
||||
}))}>
|
||||
<SelectTrigger className="h-9 w-fit min-w-35">
|
||||
<SelectTrigger className="h-9 w-auto">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
<SelectContent className="w-fit min-w-max">
|
||||
{hasCustomTidalInstanceConfigured && (<>
|
||||
<SelectItem value="tidal-qobuz-amazon">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<TidalIcon className="fill-current"/>
|
||||
@@ -495,8 +476,6 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
<TidalIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
|
||||
|
||||
<SelectItem value="tidal-qobuz">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<TidalIcon className="fill-current"/>
|
||||
@@ -518,13 +497,6 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
<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"/>
|
||||
@@ -532,6 +504,14 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
<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-qobuz">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AmazonIcon className="fill-current"/>
|
||||
@@ -553,19 +533,17 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
</Select>
|
||||
</>)}
|
||||
|
||||
{tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
|
||||
{effectiveDownloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="LOSSLESS">16-bit/44.1kHz</SelectItem>
|
||||
<SelectItem value="HI_RES_LOSSLESS">
|
||||
24-bit/48kHz
|
||||
</SelectItem>
|
||||
<SelectItem value="HI_RES_LOSSLESS">24-bit/48kHz</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>)}
|
||||
|
||||
{tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={handleQobuzQualityChange}>
|
||||
{effectiveDownloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={handleQobuzQualityChange}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -575,17 +553,16 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
</SelectContent>
|
||||
</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">
|
||||
{effectiveDownloader === "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
|
||||
</div>)}
|
||||
|
||||
</div>
|
||||
|
||||
{((tempSettings.downloader === "tidal" &&
|
||||
{((effectiveDownloader === "tidal" &&
|
||||
tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
|
||||
(tempSettings.downloader === "qobuz" &&
|
||||
(effectiveDownloader === "qobuz" &&
|
||||
tempSettings.qobuzQuality === "27") ||
|
||||
(tempSettings.downloader === "auto" &&
|
||||
(effectiveDownloader === "auto" &&
|
||||
tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pt-2">
|
||||
<Switch id="allow-fallback" checked={tempSettings.allowFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
@@ -595,66 +572,66 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
Allow Quality Fallback (16-bit)
|
||||
</Label>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(tempSettings.downloader === "auto" || tempSettings.downloader === "tidal") && (<div className="space-y-2 pt-2">
|
||||
<Label>Custom Instance</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setShowCustomTidalApiDialog(true)} className="gap-2">
|
||||
<TidalIcon />
|
||||
Configure
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="link-resolver">Link Resolver</Label>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Select value={tempSettings.linkResolver} onValueChange={(value: "songstats" | "songlink") => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
linkResolver: value,
|
||||
}))}>
|
||||
<SelectTrigger id="link-resolver" className="h-9 w-fit min-w-35">
|
||||
<SelectValue placeholder="Select a link resolver"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="songlink">
|
||||
<span className="flex items-center gap-2">
|
||||
<SonglinkIcon className="h-4 w-4 shrink-0"/>
|
||||
Songlink
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="songstats">
|
||||
<span className="flex items-center gap-2">
|
||||
<SongstatsIcon className="h-4 w-4 shrink-0"/>
|
||||
Songstats
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<Switch id="allow-link-resolver-fallback" checked={tempSettings.allowResolverFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
allowResolverFallback: checked,
|
||||
}))}/>
|
||||
<Label htmlFor="allow-link-resolver-fallback" className="text-sm font-normal cursor-pointer">
|
||||
Allow Resolver Fallback
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{activeTab === "files" && (<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-8 items-start">
|
||||
<div className="space-y-4 lg:pr-8 lg:border-r">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="download-path">Download Path</Label>
|
||||
<div className="flex gap-2">
|
||||
<InputWithContext id="download-path" value={tempSettings.downloadPath} onChange={(e) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
downloadPath: e.target.value,
|
||||
}))} placeholder="C:\Users\YourUsername\Music"/>
|
||||
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
Browse
|
||||
</Button>
|
||||
{tempSettings.customTidalApi && (<span className="max-w-[260px] truncate text-xs text-muted-foreground" title={tempSettings.customTidalApi}>
|
||||
{tempSettings.customTidalApi}
|
||||
</span>)}
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-2"/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
embedMaxQualityCover: checked,
|
||||
}))}/>
|
||||
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm font-normal">
|
||||
Embed Max Quality Cover
|
||||
</Label>
|
||||
</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 className="flex items-center gap-3">
|
||||
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
embedLyrics: checked,
|
||||
}))}/>
|
||||
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm font-normal">
|
||||
Embed Lyrics
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{activeTab === "files" && (<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm">Folder Structure</Label>
|
||||
@@ -742,31 +719,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
Create M3U8 Playlist File
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="use-first-artist-only" checked={tempSettings.useFirstArtistOnly} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
useFirstArtistOnly: checked,
|
||||
}))}/>
|
||||
<Label htmlFor="use-first-artist-only" className="text-sm cursor-pointer font-normal">
|
||||
Use First Artist Only
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="redownload-with-suffix" checked={tempSettings.redownloadWithSuffix} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
redownloadWithSuffix: checked,
|
||||
}))}/>
|
||||
<Label htmlFor="redownload-with-suffix" className="text-sm cursor-pointer font-normal">
|
||||
Redownload With Suffix
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 lg:pl-0">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="existing-file-check-mode">Existing File Check</Label>
|
||||
<Select value={tempSettings.existingFileCheckMode} onValueChange={(value: ExistingFileCheckMode) => setTempSettings((prev) => ({
|
||||
@@ -823,24 +778,6 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
filenameTemplate: e.target.value,
|
||||
}))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)}
|
||||
</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">
|
||||
Preview:{" "}
|
||||
<span className="font-mono">
|
||||
@@ -858,10 +795,92 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
</span>
|
||||
</p>)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Separator</Label>
|
||||
<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 className="flex items-center gap-3">
|
||||
<Switch id="redownload-with-suffix" checked={tempSettings.redownloadWithSuffix} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
redownloadWithSuffix: checked,
|
||||
}))}/>
|
||||
<Label htmlFor="redownload-with-suffix" className="text-sm cursor-pointer font-normal">
|
||||
Redownload With Suffix
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{activeTab === "api" && (<ApiStatusTab />)}
|
||||
{activeTab === "metadata" && (<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
embedLyrics: checked,
|
||||
}))}/>
|
||||
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm font-normal">
|
||||
Embed Lyrics
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
embedMaxQualityCover: checked,
|
||||
}))}/>
|
||||
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm font-normal">
|
||||
Embed Max Quality Cover
|
||||
</Label>
|
||||
</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 className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="use-first-artist-only" checked={tempSettings.useFirstArtistOnly} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
useFirstArtistOnly: checked,
|
||||
}))}/>
|
||||
<Label htmlFor="use-first-artist-only" className="text-sm cursor-pointer font-normal">
|
||||
Use First Artist Only
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{activeTab === "status" && (<ApiStatusTab />)}
|
||||
</div>
|
||||
|
||||
<Dialog open={showAddFontDialog} onOpenChange={(open) => open ? setShowAddFontDialog(true) : closeAddFontDialog()}>
|
||||
@@ -915,7 +934,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
<DialogContent className="sm:max-w-md [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<DialogTitle>Custom Instance</DialogTitle>
|
||||
<DialogTitle>Tidal Source</DialogTitle>
|
||||
<button type="button" onClick={() => openExternal("https://github.com/binimum/hifi-api")} className="inline-flex cursor-pointer items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline">
|
||||
How to create your own instance
|
||||
<ExternalLink className="h-3 w-3"/>
|
||||
@@ -932,8 +951,8 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
setCustomTidalApiStatus("idle");
|
||||
void persistCustomTidalApi(nextValue);
|
||||
}} placeholder="https://your-hifi-api.example"/>
|
||||
<Button type="button" variant="outline" onClick={() => void handleCheckCustomTidalApi()} disabled={!((tempSettings.customTidalApi || "").trim().startsWith("https://")) || customTidalApiStatus === "checking"}>
|
||||
{customTidalApiStatus === "checking" ? "Checking..." : "Check"}
|
||||
<Button type="button" variant="outline" className="gap-2" onClick={() => void handleCheckCustomTidalApi()} disabled={!((tempSettings.customTidalApi || "").trim().startsWith("https://")) || customTidalApiStatus === "checking"}>
|
||||
{customTidalApiStatus === "checking" ? "Checking..." : <><PlugZap className="h-4 w-4"/>Check</>}
|
||||
</Button>
|
||||
{tempSettings.customTidalApi && (<Button type="button" variant="outline" size="icon" onClick={() => {
|
||||
setCustomTidalApiStatus("idle");
|
||||
|
||||
@@ -6,18 +6,18 @@ import { ActivityIcon, type ActivityIconHandle } from "@/components/ui/activity"
|
||||
import { TerminalIcon } from "@/components/ui/terminal";
|
||||
import { FileMusicIcon, type FileMusicIconHandle } from "@/components/ui/file-music";
|
||||
import { FilePenIcon, type FilePenIconHandle } from "@/components/ui/file-pen";
|
||||
import { BugReportIcon } from "@/components/ui/bug-report-icon";
|
||||
import { CoffeeIcon } from "@/components/ui/coffee";
|
||||
import { BadgeAlertIcon } from "@/components/ui/badge-alert";
|
||||
import { GithubIcon } from "@/components/ui/github";
|
||||
import { BlocksIcon } from "@/components/ui/blocks-icon";
|
||||
import { AudioLinesIcon, type AudioLinesIconHandle } from "@/components/ui/audio-lines";
|
||||
import { ToolCaseIcon } from "@/components/ui/tool-case";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { openExternal } from "@/lib/utils";
|
||||
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "about" | "history";
|
||||
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "projects" | "support" | "history";
|
||||
interface SidebarProps {
|
||||
currentPage: PageType;
|
||||
onPageChange: (page: PageType) => void;
|
||||
@@ -100,7 +100,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant={["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`}>
|
||||
<BlocksIcon size={20} loop={true}/>
|
||||
<ToolCaseIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -134,7 +134,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => setIsIssuesDialogOpen(true)}>
|
||||
<GithubIcon size={20}/>
|
||||
<BugReportIcon size={20} loop={true}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
@@ -176,23 +176,23 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
|
||||
<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 variant={currentPage === "projects" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "projects" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("projects")}>
|
||||
<BlocksIcon size={20} loop={true}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>About</p>
|
||||
<p>Other Projects</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
||||
<Button variant={currentPage === "support" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "support" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("support")}>
|
||||
<CoffeeIcon size={20} loop={true}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Support me on Ko-fi</p>
|
||||
<p>Support Me</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useState } from "react";
|
||||
import { CircleCheck, Copy } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { openExternal } from "@/lib/utils";
|
||||
import KofiLogo from "@/assets/ko-fi.gif";
|
||||
import KofiSvg from "@/assets/kofi_symbol.svg";
|
||||
import PatreonLogo from "@/assets/patreon.svg";
|
||||
import PatreonSymbol from "@/assets/patreon_symbol.svg";
|
||||
import UsdtBarcode from "@/assets/usdt.jpg";
|
||||
|
||||
export function SupportPage() {
|
||||
const [copiedUsdt, setCopiedUsdt] = useState(false);
|
||||
const [copiedEmail, setCopiedEmail] = useState(false);
|
||||
return (<div className="flex flex-col space-y-3">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Support Me</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center p-4">
|
||||
<div className="grid w-full max-w-5xl overflow-hidden rounded-xl border bg-card shadow-sm md:grid-cols-3">
|
||||
<div className="flex min-h-[22rem] flex-col items-center justify-between space-y-6 border-b p-6 md:border-b-0 md:border-r">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="h-32 flex items-center justify-center w-full relative">
|
||||
<img src={KofiLogo} className="w-72 absolute pointer-events-none" alt="Ko-fi"/>
|
||||
</div>
|
||||
<h4 className="font-semibold text-foreground">Support via Ko-fi</h4>
|
||||
<p className="text-sm text-muted-foreground text-center px-4">
|
||||
Buy me a coffee to help keep development going.
|
||||
</p>
|
||||
</div>
|
||||
<Button className="h-10 w-full gap-2 bg-[#72a4f2] text-sm font-semibold text-white hover:bg-[#5f8cd6]" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
||||
<img src={KofiSvg} className="h-6 w-6 shrink-0" alt="" aria-hidden="true"/>
|
||||
Support me on Ko-fi
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-[22rem] flex-col items-center justify-between space-y-6 border-b p-6 md:border-b-0 md:border-r">
|
||||
<div className="flex flex-col items-center space-y-4 w-full">
|
||||
<div className="h-32 flex items-center justify-center w-full px-4">
|
||||
<img src={PatreonLogo} className="w-56 max-w-full brightness-0 dark:brightness-100" alt="Patreon"/>
|
||||
</div>
|
||||
<h4 className="font-semibold text-foreground">Support via Patreon</h4>
|
||||
<p className="text-sm text-muted-foreground text-center px-4">
|
||||
Join on Patreon to help fund the project and follow updates.
|
||||
</p>
|
||||
</div>
|
||||
<Button className="h-10 w-full gap-2 bg-[#ff424d] text-sm font-semibold text-white hover:bg-[#e63945]" onClick={() => openExternal("https://www.patreon.com/cw/afkarxyz")}>
|
||||
<img src={PatreonSymbol} className="h-5 w-5 shrink-0" alt="" aria-hidden="true"/>
|
||||
Support me on Patreon
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-[22rem] flex-col items-center justify-between space-y-6 p-6">
|
||||
<div className="flex flex-col items-center space-y-4 w-full">
|
||||
<div className="h-32 flex items-center justify-center">
|
||||
<div className="rounded-xl border bg-white p-2 shadow-sm">
|
||||
<img src={UsdtBarcode} className="h-24 w-24 object-contain" alt="USDT Barcode"/>
|
||||
</div>
|
||||
</div>
|
||||
<h4 className="font-semibold text-foreground">USDT (TRC20)</h4>
|
||||
<p className="text-sm text-muted-foreground text-center px-4">
|
||||
Prefer crypto? Use the QR code or wallet address below.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-full items-center justify-between gap-2 rounded-lg border bg-muted/50 py-1.5 pl-3 pr-1.5">
|
||||
<code className="truncate text-xs font-mono text-muted-foreground" 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 className="mt-4 w-full max-w-5xl rounded-xl border bg-muted/30 px-4 py-3 text-center text-sm text-muted-foreground">
|
||||
If you have any questions or need help with donating, feel free to reach out via{" "}
|
||||
<button type="button" className="font-medium text-foreground underline-offset-4 hover:underline" onClick={() => openExternal("https://t.me/afkarxyz")}>
|
||||
Telegram
|
||||
</button>{" "}
|
||||
or{" "}
|
||||
<button type="button" className="font-medium text-foreground underline-offset-4 hover:underline" onClick={() => {
|
||||
navigator.clipboard.writeText("hi@afkarxyz.fyi");
|
||||
setCopiedEmail(true);
|
||||
setTimeout(() => setCopiedEmail(false), 500);
|
||||
}}>
|
||||
{copiedEmail ? "copied" : "hi@afkarxyz.fyi"}
|
||||
</button>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
@@ -176,7 +176,7 @@ export function TitleBar() {
|
||||
</div>)}
|
||||
</div>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem onClick={() => openExternal("https://afkarxyz.qzz.io")} className="gap-2">
|
||||
<MenubarItem onClick={() => openExternal("https://afkarxyz.fyi")} className="gap-2">
|
||||
<Globe className="w-4 h-4 opacity-70"/>
|
||||
<span>Website</span>
|
||||
</MenubarItem>
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
"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 BadgeAlertIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
interface BadgeAlertIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
}
|
||||
const ICON_VARIANTS: Variants = {
|
||||
normal: { scale: 1, rotate: 0 },
|
||||
animate: {
|
||||
scale: [1, 1.1, 1.1, 1.1, 1],
|
||||
rotate: [0, -3, 3, -2, 2, 0],
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
times: [0, 0.2, 0.4, 0.6, 1],
|
||||
ease: "easeInOut",
|
||||
},
|
||||
},
|
||||
};
|
||||
const BadgeAlertIcon = forwardRef<BadgeAlertIconHandle, BadgeAlertIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...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(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||
<motion.svg animate={controls} fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" variants={ICON_VARIANTS} viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/>
|
||||
<line x1="12" x2="12" y1="8" y2="12"/>
|
||||
<line x1="12" x2="12.01" y1="16" y2="16"/>
|
||||
</motion.svg>
|
||||
</div>);
|
||||
});
|
||||
BadgeAlertIcon.displayName = "BadgeAlertIcon";
|
||||
export { BadgeAlertIcon };
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import type { Transition, Variants } from "motion/react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useEffect, useState, type HTMLAttributes } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ReportIconMode = "bug" | "bulb";
|
||||
|
||||
interface BugReportIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
loop?: boolean;
|
||||
}
|
||||
|
||||
const LOOP_INTERVAL_MS = 2200;
|
||||
|
||||
const GROUP_VARIANTS: Variants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: [0, 0, 0.2, 1],
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.18,
|
||||
ease: [0.4, 0, 1, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const DRAW_VARIANTS: Variants = {
|
||||
hidden: {
|
||||
pathLength: 0,
|
||||
opacity: 0,
|
||||
},
|
||||
visible: {
|
||||
pathLength: 1,
|
||||
opacity: 1,
|
||||
},
|
||||
exit: {
|
||||
pathLength: 1,
|
||||
opacity: 0,
|
||||
},
|
||||
};
|
||||
|
||||
function createDrawTransition(delay = 0, duration = 0.36): Transition {
|
||||
return {
|
||||
duration,
|
||||
delay,
|
||||
ease: [0.4, 0, 0.2, 1],
|
||||
opacity: { delay },
|
||||
};
|
||||
}
|
||||
|
||||
function BugPaths() {
|
||||
return (<>
|
||||
<motion.path d="m8 2 1.88 1.88" transition={createDrawTransition(0)} variants={DRAW_VARIANTS}/>
|
||||
<motion.path d="M14.12 3.88 16 2" transition={createDrawTransition(0.04)} variants={DRAW_VARIANTS}/>
|
||||
<motion.path d="M9 7.13V6a3 3 0 1 1 6 0v1.13" transition={createDrawTransition(0.08)} variants={DRAW_VARIANTS}/>
|
||||
<motion.path d="M6.53 9A4 4 0 0 1 3 5" transition={createDrawTransition(0.14)} variants={DRAW_VARIANTS}/>
|
||||
<motion.path d="M17.47 9A4 4 0 0 0 21 5" transition={createDrawTransition(0.18)} variants={DRAW_VARIANTS}/>
|
||||
<motion.path d="M12 20v-9" transition={createDrawTransition(0.24)} variants={DRAW_VARIANTS}/>
|
||||
<motion.path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" transition={createDrawTransition(0.3, 0.42)} variants={DRAW_VARIANTS}/>
|
||||
<motion.path d="M22 13h-4" transition={createDrawTransition(0.42)} variants={DRAW_VARIANTS}/>
|
||||
<motion.path d="M6 13H2" transition={createDrawTransition(0.46)} variants={DRAW_VARIANTS}/>
|
||||
<motion.path d="M21 21a4 4 0 0 0-3.81-4" transition={createDrawTransition(0.52)} variants={DRAW_VARIANTS}/>
|
||||
<motion.path d="M3 21a4 4 0 0 1 3.81-4" transition={createDrawTransition(0.56)} variants={DRAW_VARIANTS}/>
|
||||
</>);
|
||||
}
|
||||
|
||||
function BulbPaths() {
|
||||
return (<>
|
||||
<motion.path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5" transition={createDrawTransition(0, 0.46)} variants={DRAW_VARIANTS}/>
|
||||
<motion.path d="M9 18h6" transition={createDrawTransition(0.16)} variants={DRAW_VARIANTS}/>
|
||||
<motion.path d="M10 22h4" transition={createDrawTransition(0.24)} variants={DRAW_VARIANTS}/>
|
||||
</>);
|
||||
}
|
||||
|
||||
function ReportIconGroup({ mode }: { mode: ReportIconMode }) {
|
||||
return (<motion.g animate="visible" exit="exit" initial="hidden" variants={GROUP_VARIANTS}>
|
||||
{mode === "bug" ? <BugPaths/> : <BulbPaths/>}
|
||||
</motion.g>);
|
||||
}
|
||||
|
||||
function StaticBugIcon() {
|
||||
return (<g>
|
||||
<path d="m8 2 1.88 1.88"/>
|
||||
<path d="M14.12 3.88 16 2"/>
|
||||
<path d="M9 7.13V6a3 3 0 1 1 6 0v1.13"/>
|
||||
<path d="M6.53 9A4 4 0 0 1 3 5"/>
|
||||
<path d="M17.47 9A4 4 0 0 0 21 5"/>
|
||||
<path d="M12 20v-9"/>
|
||||
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z"/>
|
||||
<path d="M22 13h-4"/>
|
||||
<path d="M6 13H2"/>
|
||||
<path d="M21 21a4 4 0 0 0-3.81-4"/>
|
||||
<path d="M3 21a4 4 0 0 1 3.81-4"/>
|
||||
</g>);
|
||||
}
|
||||
|
||||
function BugReportIcon({ className, size = 28, loop = false, ...props }: BugReportIconProps) {
|
||||
const [mode, setMode] = useState<ReportIconMode>("bug");
|
||||
|
||||
useEffect(() => {
|
||||
if (!loop) {
|
||||
setMode("bug");
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
setMode((currentMode) => currentMode === "bug" ? "bulb" : "bug");
|
||||
}, LOOP_INTERVAL_MS);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [loop]);
|
||||
|
||||
return (<div className={cn("flex items-center justify-center", className)} {...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">
|
||||
{loop ? (<AnimatePresence>
|
||||
<ReportIconGroup key={mode} mode={mode}/>
|
||||
</AnimatePresence>) : (<StaticBugIcon/>)}
|
||||
</svg>
|
||||
</div>);
|
||||
}
|
||||
|
||||
export { BugReportIcon };
|
||||
@@ -1,102 +0,0 @@
|
||||
"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 };
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
import type { Variants } from 'motion/react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
import { motion, useAnimation } from 'motion/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface ToolCaseIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
|
||||
interface ToolCaseIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const DRAW_VARIANTS: Variants = {
|
||||
normal: {
|
||||
pathLength: 1,
|
||||
opacity: 1,
|
||||
},
|
||||
animate: {
|
||||
pathLength: [0, 1],
|
||||
opacity: [0, 1],
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const HANDLE_VARIANTS: Variants = {
|
||||
normal: {
|
||||
scaleX: 1,
|
||||
originX: '50%',
|
||||
},
|
||||
animate: {
|
||||
scaleX: [0.6, 1.1, 1],
|
||||
originX: '50%',
|
||||
transition: {
|
||||
duration: 0.45,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ToolCaseIcon = forwardRef<ToolCaseIconHandle, ToolCaseIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...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) {
|
||||
controls.start('animate');
|
||||
}
|
||||
else {
|
||||
onMouseEnter?.(e);
|
||||
}
|
||||
}, [controls, onMouseEnter]);
|
||||
|
||||
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('normal');
|
||||
}
|
||||
else {
|
||||
onMouseLeave?.(e);
|
||||
}
|
||||
}, [controls, onMouseLeave]);
|
||||
|
||||
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<motion.path d="M10 15h4" variants={HANDLE_VARIANTS} animate={controls} initial="normal"/>
|
||||
<motion.path d="m14.817 10.995-.971-1.45 1.034-1.232a2 2 0 0 0-2.025-3.238l-1.82.364L9.91 3.885a2 2 0 0 0-3.625.748L6.141 6.55l-1.725.426a2 2 0 0 0-.19 3.756l.657.27" variants={DRAW_VARIANTS} animate={controls} initial="normal"/>
|
||||
<motion.path d="m18.822 10.995 2.26-5.38a1 1 0 0 0-.557-1.318L16.954 2.9a1 1 0 0 0-1.281.533l-.924 2.122" variants={DRAW_VARIANTS} animate={controls} initial="normal"/>
|
||||
<motion.path d="M4 12.006A1 1 0 0 1 4.994 11H19a1 1 0 0 1 1 1v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" variants={DRAW_VARIANTS} animate={controls} initial="normal"/>
|
||||
</svg>
|
||||
</div>);
|
||||
});
|
||||
|
||||
ToolCaseIcon.displayName = 'ToolCaseIcon';
|
||||
|
||||
export { ToolCaseIcon };
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { API_SOURCES, checkApiStatus, getApiStatusState, subscribeApiStatus, } from "@/lib/api-status";
|
||||
import { API_SOURCES, checkApiStatus, checkCurrentApiStatusesOnly, checkSpotiFLACNextStatusesOnly, getApiStatusState, subscribeApiStatus, } from "@/lib/api-status";
|
||||
export function useApiStatus() {
|
||||
const [state, setState] = useState(getApiStatusState);
|
||||
useEffect(() => {
|
||||
@@ -11,5 +11,7 @@ export function useApiStatus() {
|
||||
...state,
|
||||
sources: API_SOURCES,
|
||||
checkOne: (sourceId: string) => checkApiStatus(sourceId),
|
||||
checkAllCurrent: () => checkCurrentApiStatusesOnly(),
|
||||
checkAllNext: () => checkSpotiFLACNextStatusesOnly(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { downloadTrack, fetchSpotifyMetadata } from "@/lib/api";
|
||||
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
|
||||
import { getSettings, hasConfiguredCustomTidalApi, parseTemplate, sanitizeAutoOrder, type TemplateData } from "@/lib/settings";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils";
|
||||
import { logger } from "@/lib/logger";
|
||||
@@ -86,10 +86,11 @@ export function useDownload(region: string) {
|
||||
setDownloadRemainingCount(Math.max(0, safeTotalCount - safeCompletedCount));
|
||||
};
|
||||
const downloadWithAutoFallback = async (id: string, settings: any, trackName?: string, artistName?: string, albumName?: string, playlistName?: string, position?: number, spotifyId?: string, durationMs?: number, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
|
||||
const service = settings.downloader;
|
||||
const allowTidal = hasConfiguredCustomTidalApi(settings.customTidalApi);
|
||||
const service = settings.downloader === "tidal" && !allowTidal ? "auto" : settings.downloader;
|
||||
const query = trackName && artistName ? `${trackName} ${artistName} ` : undefined;
|
||||
const os = settings.operatingSystem;
|
||||
const customTidalApi = typeof settings.customTidalApi === "string" && settings.customTidalApi.trim().startsWith("https://")
|
||||
const customTidalApi = allowTidal && typeof settings.customTidalApi === "string" && settings.customTidalApi.trim().startsWith("https://")
|
||||
? settings.customTidalApi.trim().replace(/\/+$/g, "")
|
||||
: undefined;
|
||||
let outputDir = settings.downloadPath;
|
||||
@@ -193,7 +194,7 @@ export function useDownload(region: string) {
|
||||
itemID = await AddToDownloadQueue(id, trackName || "", displayArtist || "", albumName || "");
|
||||
}
|
||||
if (service === "auto") {
|
||||
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
||||
const order = sanitizeAutoOrder(settings.autoOrder, allowTidal).split("-");
|
||||
let streamingURLs: any = null;
|
||||
if (spotifyId && shouldFetchStreamingURLs(order)) {
|
||||
try {
|
||||
@@ -416,7 +417,8 @@ export function useDownload(region: string) {
|
||||
return singleServiceResponse;
|
||||
};
|
||||
const downloadWithItemID = async (settings: any, itemID: string, trackName?: string, artistName?: string, albumName?: string, folderName?: string, position?: number, spotifyId?: string, durationMs?: number, isAlbum?: boolean, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
|
||||
const service = settings.downloader;
|
||||
const allowTidal = hasConfiguredCustomTidalApi(settings.customTidalApi);
|
||||
const service = settings.downloader === "tidal" && !allowTidal ? "auto" : settings.downloader;
|
||||
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
|
||||
const os = settings.operatingSystem;
|
||||
let outputDir = settings.downloadPath;
|
||||
@@ -477,7 +479,7 @@ export function useDownload(region: string) {
|
||||
}
|
||||
}
|
||||
if (service === "auto") {
|
||||
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
||||
const order = sanitizeAutoOrder(settings.autoOrder, allowTidal).split("-");
|
||||
let streamingURLs: any = null;
|
||||
if (spotifyId && shouldFetchStreamingURLs(order)) {
|
||||
try {
|
||||
|
||||
+234
-58
@@ -1,25 +1,43 @@
|
||||
import { CheckAPIStatus } from "../../wailsjs/go/main/App";
|
||||
import { CheckAPIStatus, CheckCustomTidalAPI } from "../../wailsjs/go/main/App";
|
||||
import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout";
|
||||
import { getSettings, hasConfiguredCustomTidalApi } from "@/lib/settings";
|
||||
|
||||
export type ApiCheckStatus = "checking" | "online" | "offline" | "idle";
|
||||
|
||||
export interface ApiSource {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface SpotiFLACNextSource {
|
||||
id: string;
|
||||
name: string;
|
||||
statusKey?: string;
|
||||
statusPrefix?: string;
|
||||
}
|
||||
|
||||
type SpotiFLACNextStatusResponse = Partial<Record<string, string>>;
|
||||
type ApiStatusTargetReport = {
|
||||
target?: string;
|
||||
label?: string;
|
||||
online?: boolean;
|
||||
message?: string;
|
||||
};
|
||||
type ApiStatusReport = {
|
||||
type?: string;
|
||||
online?: boolean;
|
||||
require_all?: boolean;
|
||||
details?: ApiStatusTargetReport[];
|
||||
};
|
||||
|
||||
export const API_SOURCES: ApiSource[] = [
|
||||
{ id: "tidal", type: "tidal", name: "Tidal", url: "" },
|
||||
{ id: "qobuz", type: "qobuz", name: "Qobuz", url: "" },
|
||||
{ id: "amazon", type: "amazon", name: "Amazon Music", url: "" },
|
||||
{ id: "musicbrainz", type: "musicbrainz", name: "MusicBrainz", url: "https://musicbrainz.org" },
|
||||
];
|
||||
|
||||
export const SPOTIFLAC_NEXT_SOURCES: SpotiFLACNextSource[] = [
|
||||
{ id: "tidal", name: "Tidal", statusKey: "tidal" },
|
||||
{ id: "qobuz", name: "Qobuz", statusPrefix: "qobuz_" },
|
||||
@@ -27,43 +45,101 @@ export const SPOTIFLAC_NEXT_SOURCES: SpotiFLACNextSource[] = [
|
||||
{ id: "deezer", name: "Deezer", statusPrefix: "deezer_" },
|
||||
{ id: "apple", name: "Apple Music", statusKey: "apple" },
|
||||
];
|
||||
const SPOTIFLAC_NEXT_STATUS_URL = "https://gist.githubusercontent.com/afkarxyz/6e57cd362cbd67f889e3a91a76254a5e/raw";
|
||||
const SPOTIFLAC_NEXT_MAX_ATTEMPTS = 3;
|
||||
const SPOTIFLAC_NEXT_RETRY_DELAY_MS = 1200;
|
||||
|
||||
const SPOTIFLAC_STATUS_URL = "https://gist.githubusercontent.com/afkarxyz/6e57cd362cbd67f889e3a91a76254a5e/raw";
|
||||
const SPOTIFLAC_CURRENT_AMAZON_STATUS_KEY = "amazon_a";
|
||||
const SPOTIFLAC_STATUS_MAX_ATTEMPTS = 3;
|
||||
const SPOTIFLAC_STATUS_RETRY_DELAY_MS = 1200;
|
||||
const CheckAPIStatusReport = (apiType: string, apiURL: string): Promise<ApiStatusReport> => (window as any)["go"]["main"]["App"]["CheckAPIStatusReport"](apiType, apiURL);
|
||||
const LogStatusConsole = (level: string, message: string): Promise<void> => (window as any)["go"]["main"]["App"]["LogStatusConsole"](level, message);
|
||||
|
||||
type ApiStatusState = {
|
||||
checkingSources: Record<string, boolean>;
|
||||
statuses: Record<string, ApiCheckStatus>;
|
||||
nextStatuses: Record<string, ApiCheckStatus>;
|
||||
};
|
||||
|
||||
let apiStatusState: ApiStatusState = {
|
||||
checkingSources: {},
|
||||
statuses: {},
|
||||
nextStatuses: {},
|
||||
};
|
||||
|
||||
let activeCheckCurrentOnly: Promise<void> | null = null;
|
||||
let activeCheckNextOnly: Promise<void> | null = null;
|
||||
let activeStatusPayloadFetch: Promise<SpotiFLACNextStatusResponse> | null = null;
|
||||
|
||||
const activeSourceChecks = new Map<string, Promise<void>>();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emitApiStatusChange() {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState) {
|
||||
apiStatusState = updater(apiStatusState);
|
||||
emitApiStatusChange();
|
||||
}
|
||||
async function checkSourceStatus(source: ApiSource): Promise<ApiCheckStatus> {
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
function sendStatusConsole(level: "info" | "warning" | "error", message: string): void {
|
||||
try {
|
||||
const isOnline = await withTimeout(CheckAPIStatus(source.type, source.url), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.name}`);
|
||||
return isOnline ? "online" : "offline";
|
||||
void LogStatusConsole(level, message);
|
||||
}
|
||||
catch {
|
||||
return "offline";
|
||||
return;
|
||||
}
|
||||
}
|
||||
function logStatusInfo(message: string): void {
|
||||
sendStatusConsole("info", message);
|
||||
}
|
||||
function logStatusWarning(message: string): void {
|
||||
sendStatusConsole("warning", message);
|
||||
}
|
||||
function logStatusError(message: string): void {
|
||||
sendStatusConsole("error", message);
|
||||
}
|
||||
function truncateStatusMessage(message?: string, maxLen = 180): string {
|
||||
const trimmed = (message || "").trim();
|
||||
if (trimmed.length <= maxLen) {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed.slice(0, maxLen) + "...";
|
||||
}
|
||||
function logQobuzStatusReport(report: ApiStatusReport): void {
|
||||
const details = Array.isArray(report.details) ? report.details : [];
|
||||
if (details.length === 0) {
|
||||
logStatusWarning("[Status][Qobuz] No provider details were returned.");
|
||||
return;
|
||||
}
|
||||
const onlineCount = details.filter((detail) => detail.online === true).length;
|
||||
logStatusInfo(`[Status][Qobuz] Provider check completed: ${onlineCount}/${details.length} providers online.`);
|
||||
for (const detail of details) {
|
||||
const label = detail.label || detail.target || "Unknown provider";
|
||||
const suffix = detail.message ? ` - ${truncateStatusMessage(detail.message)}` : "";
|
||||
if (detail.online) {
|
||||
logStatusInfo(`[Status][Qobuz] ${label}: online${suffix}`);
|
||||
}
|
||||
else {
|
||||
logStatusWarning(`[Status][Qobuz] ${label}: offline${suffix}`);
|
||||
}
|
||||
}
|
||||
if (report.online) {
|
||||
logStatusInfo(`[Status][Qobuz] SpotiFLAC Qobuz is online (${onlineCount}/${details.length} providers online).`);
|
||||
}
|
||||
else {
|
||||
logStatusWarning(`[Status][Qobuz] SpotiFLAC Qobuz marked maintenance because all ${details.length} providers are offline.`);
|
||||
}
|
||||
}
|
||||
|
||||
function anyNextVariantUp(values: Array<string | undefined>): ApiCheckStatus {
|
||||
return values.some((value) => value === "up") ? "online" : "offline";
|
||||
}
|
||||
|
||||
function getNextSourceValues(payload: SpotiFLACNextStatusResponse, source: SpotiFLACNextSource): string[] {
|
||||
if (source.statusKey) {
|
||||
const value = payload[source.statusKey];
|
||||
@@ -80,9 +156,11 @@ function getNextSourceValues(payload: SpotiFLACNextStatusResponse, source: Spoti
|
||||
}
|
||||
return values;
|
||||
}
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
|
||||
function getCurrentAmazonStatus(payload: SpotiFLACNextStatusResponse): ApiCheckStatus {
|
||||
return payload[SPOTIFLAC_CURRENT_AMAZON_STATUS_KEY] === "up" ? "online" : "offline";
|
||||
}
|
||||
|
||||
function getSafeNextStatusesFallback(currentStatuses: Record<string, ApiCheckStatus>): Record<string, ApiCheckStatus> {
|
||||
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
|
||||
const current = currentStatuses[source.id];
|
||||
@@ -90,57 +168,142 @@ function getSafeNextStatusesFallback(currentStatuses: Record<string, ApiCheckSta
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
async function fetchSpotiFLACNextStatusesOnce(): Promise<Record<string, ApiCheckStatus>> {
|
||||
const response = await withTimeout(fetch(SPOTIFLAC_NEXT_STATUS_URL, {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
}), CHECK_TIMEOUT_MS, "SpotiFLAC Next status check timed out after 10 seconds");
|
||||
if (!response.ok) {
|
||||
throw new Error(`SpotiFLAC Next status returned ${response.status}`);
|
||||
}
|
||||
const payload = (await response.json()) as SpotiFLACNextStatusResponse;
|
||||
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
|
||||
acc[source.id] = anyNextVariantUp(getNextSourceValues(payload, source));
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
async function checkSpotiFLACNextStatuses(): Promise<Record<string, ApiCheckStatus>> {
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 1; attempt <= SPOTIFLAC_NEXT_MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
return await fetchSpotiFLACNextStatusesOnce();
|
||||
}
|
||||
catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < SPOTIFLAC_NEXT_MAX_ATTEMPTS) {
|
||||
await delay(SPOTIFLAC_NEXT_RETRY_DELAY_MS * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error("SpotiFLAC Next status check failed");
|
||||
}
|
||||
export function getApiStatusState(): ApiStatusState {
|
||||
return apiStatusState;
|
||||
}
|
||||
export function subscribeApiStatus(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
|
||||
function hasCurrentResults(): boolean {
|
||||
return API_SOURCES.some((source) => {
|
||||
const status = apiStatusState.statuses[source.id];
|
||||
return status === "online" || status === "offline";
|
||||
});
|
||||
}
|
||||
|
||||
function hasSpotiFLACNextResults(): boolean {
|
||||
return SPOTIFLAC_NEXT_SOURCES.some((source) => {
|
||||
const status = apiStatusState.nextStatuses[source.id];
|
||||
return status === "online" || status === "offline";
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchSpotiFLACStatusPayloadOnce(): Promise<SpotiFLACNextStatusResponse> {
|
||||
const response = await withTimeout(fetch(SPOTIFLAC_STATUS_URL, {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
}), CHECK_TIMEOUT_MS, "SpotiFLAC status check timed out after 10 seconds");
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`SpotiFLAC status returned ${response.status}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as SpotiFLACNextStatusResponse;
|
||||
}
|
||||
|
||||
async function fetchSpotiFLACStatusPayload(): Promise<SpotiFLACNextStatusResponse> {
|
||||
if (activeStatusPayloadFetch) {
|
||||
return activeStatusPayloadFetch;
|
||||
}
|
||||
|
||||
activeStatusPayloadFetch = (async () => {
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 1; attempt <= SPOTIFLAC_STATUS_MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
return await fetchSpotiFLACStatusPayloadOnce();
|
||||
}
|
||||
catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < SPOTIFLAC_STATUS_MAX_ATTEMPTS) {
|
||||
await delay(SPOTIFLAC_STATUS_RETRY_DELAY_MS * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error("SpotiFLAC status check failed");
|
||||
})();
|
||||
|
||||
try {
|
||||
return await activeStatusPayloadFetch;
|
||||
}
|
||||
finally {
|
||||
activeStatusPayloadFetch = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkSourceStatus(source: ApiSource): Promise<ApiCheckStatus> {
|
||||
try {
|
||||
if (source.id === "tidal") {
|
||||
const customTidalApi = getSettings().customTidalApi;
|
||||
if (!hasConfiguredCustomTidalApi(customTidalApi)) {
|
||||
logStatusWarning("[Status][Tidal] Marked maintenance because no custom Tidal instance is configured.");
|
||||
return "offline";
|
||||
}
|
||||
const isOnline = await withTimeout(CheckCustomTidalAPI(customTidalApi), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.name}`);
|
||||
return isOnline ? "online" : "offline";
|
||||
}
|
||||
|
||||
if (source.id === "amazon") {
|
||||
const payload = await fetchSpotiFLACStatusPayload();
|
||||
return getCurrentAmazonStatus(payload);
|
||||
}
|
||||
|
||||
if (source.id === "qobuz") {
|
||||
logStatusInfo("[Status][Qobuz] Checking current SpotiFLAC providers...");
|
||||
const report = await withTimeout(CheckAPIStatusReport(source.type, source.url), CHECK_TIMEOUT_MS, `API status report timed out after 10 seconds for ${source.name}`);
|
||||
logQobuzStatusReport(report);
|
||||
return report.online ? "online" : "offline";
|
||||
}
|
||||
|
||||
const isOnline = await withTimeout(CheckAPIStatus(source.type, source.url), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.name}`);
|
||||
return isOnline ? "online" : "offline";
|
||||
}
|
||||
catch (error) {
|
||||
if (source.id === "qobuz") {
|
||||
logStatusError(`[Status][Qobuz] Provider check failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
return "offline";
|
||||
}
|
||||
}
|
||||
|
||||
async function checkSpotiFLACNextStatuses(): Promise<Record<string, ApiCheckStatus>> {
|
||||
const payload = await fetchSpotiFLACStatusPayload();
|
||||
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
|
||||
acc[source.id] = anyNextVariantUp(getNextSourceValues(payload, source));
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function getApiStatusState(): ApiStatusState {
|
||||
return apiStatusState;
|
||||
}
|
||||
|
||||
export function subscribeApiStatus(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkCurrentApiStatusesOnly(): Promise<void> {
|
||||
if (activeCheckCurrentOnly) {
|
||||
return activeCheckCurrentOnly;
|
||||
}
|
||||
|
||||
activeCheckCurrentOnly = (async () => {
|
||||
await Promise.all(API_SOURCES.map((source) => checkApiStatus(source.id)));
|
||||
})();
|
||||
|
||||
try {
|
||||
await activeCheckCurrentOnly;
|
||||
}
|
||||
finally {
|
||||
activeCheckCurrentOnly = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
|
||||
if (activeCheckNextOnly) {
|
||||
return activeCheckNextOnly;
|
||||
}
|
||||
|
||||
activeCheckNextOnly = (async () => {
|
||||
const checkingNextStatuses = Object.fromEntries(SPOTIFLAC_NEXT_SOURCES.map((source) => [source.id, "checking" as ApiCheckStatus]));
|
||||
setApiStatusState((current) => ({
|
||||
@@ -150,11 +313,8 @@ export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
|
||||
...checkingNextStatuses,
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
setApiStatusState((current) => ({
|
||||
...current,
|
||||
nextStatuses: { ...current.nextStatuses },
|
||||
}));
|
||||
const nextStatuses = await checkSpotiFLACNextStatuses();
|
||||
setApiStatusState((current) => ({
|
||||
...current,
|
||||
@@ -170,26 +330,40 @@ export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
|
||||
nextStatuses: getSafeNextStatusesFallback(current.nextStatuses),
|
||||
}));
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
await activeCheckNextOnly;
|
||||
}
|
||||
finally {
|
||||
activeCheckNextOnly = null;
|
||||
}
|
||||
})();
|
||||
return activeCheckNextOnly;
|
||||
}
|
||||
export function ensureSpotiFLACNextStatusCheckStarted(): void {
|
||||
|
||||
export function ensureApiStatusCheckStarted(): void {
|
||||
if (!activeCheckCurrentOnly && !hasCurrentResults()) {
|
||||
void checkCurrentApiStatusesOnly();
|
||||
}
|
||||
if (!activeCheckNextOnly && !hasSpotiFLACNextResults()) {
|
||||
void checkSpotiFLACNextStatusesOnly();
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureSpotiFLACNextStatusCheckStarted(): void {
|
||||
ensureApiStatusCheckStarted();
|
||||
}
|
||||
|
||||
export async function checkApiStatus(sourceId: string): Promise<void> {
|
||||
const source = API_SOURCES.find((item) => item.id === sourceId);
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeCheck = activeSourceChecks.get(sourceId);
|
||||
if (activeCheck) {
|
||||
return activeCheck;
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
setApiStatusState((current) => ({
|
||||
...current,
|
||||
@@ -202,6 +376,7 @@ export async function checkApiStatus(sourceId: string): Promise<void> {
|
||||
[sourceId]: "checking",
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const status = await checkSourceStatus(source);
|
||||
setApiStatusState((current) => ({
|
||||
@@ -223,6 +398,7 @@ export async function checkApiStatus(sourceId: string): Promise<void> {
|
||||
activeSourceChecks.delete(sourceId);
|
||||
}
|
||||
})();
|
||||
|
||||
activeSourceChecks.set(sourceId, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
tidalQuality: "LOSSLESS",
|
||||
qobuzQuality: "6",
|
||||
amazonQuality: "original",
|
||||
autoOrder: "tidal-qobuz-amazon",
|
||||
autoOrder: "qobuz-amazon",
|
||||
autoQuality: "16",
|
||||
allowFallback: true,
|
||||
createPlaylistFolder: true,
|
||||
@@ -521,6 +521,33 @@ function normalizeCustomTidalApi(value: unknown): string {
|
||||
? value.trim().replace(/\/+$/g, "")
|
||||
: "";
|
||||
}
|
||||
export function hasConfiguredCustomTidalApi(value: unknown): boolean {
|
||||
return normalizeCustomTidalApi(value).startsWith("https://");
|
||||
}
|
||||
export function sanitizeAutoOrder(order: unknown, allowTidal: boolean): string {
|
||||
const allowedServices = allowTidal
|
||||
? new Set(["tidal", "qobuz", "amazon"])
|
||||
: new Set(["qobuz", "amazon"]);
|
||||
const fallbackOrder = allowTidal ? "tidal-qobuz-amazon" : "qobuz-amazon";
|
||||
if (typeof order !== "string") {
|
||||
return fallbackOrder;
|
||||
}
|
||||
const normalized = order
|
||||
.split("-")
|
||||
.map((part) => part.trim().toLowerCase())
|
||||
.filter((part, index, parts) => part !== "" && allowedServices.has(part) && parts.indexOf(part) === index);
|
||||
return normalized.length >= 2 ? normalized.join("-") : fallbackOrder;
|
||||
}
|
||||
function normalizeDownloader(value: unknown, allowTidal: boolean): Settings["downloader"] {
|
||||
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
if (normalized === "tidal") {
|
||||
return allowTidal ? "tidal" : "auto";
|
||||
}
|
||||
if (normalized === "qobuz" || normalized === "amazon" || normalized === "auto") {
|
||||
return normalized;
|
||||
}
|
||||
return DEFAULT_SETTINGS.downloader;
|
||||
}
|
||||
function normalizeExistingFileCheckMode(mode: unknown): ExistingFileCheckMode {
|
||||
switch (typeof mode === "string" ? mode.trim().toLowerCase() : "") {
|
||||
case "isrc":
|
||||
@@ -583,12 +610,15 @@ function normalizeSettingsPayload(settings: SettingsPayload): SettingsPayload {
|
||||
normalized.amazonQuality = "original";
|
||||
}
|
||||
if (!("autoOrder" in normalized)) {
|
||||
normalized.autoOrder = "tidal-qobuz-amazon";
|
||||
normalized.autoOrder = DEFAULT_SETTINGS.autoOrder;
|
||||
}
|
||||
if (!("autoQuality" in normalized)) {
|
||||
normalized.autoQuality = "16";
|
||||
}
|
||||
normalized.customTidalApi = normalizeCustomTidalApi(normalized.customTidalApi);
|
||||
const allowTidal = hasConfiguredCustomTidalApi(normalized.customTidalApi);
|
||||
normalized.downloader = normalizeDownloader(normalized.downloader, allowTidal);
|
||||
normalized.autoOrder = sanitizeAutoOrder(normalized.autoOrder, allowTidal);
|
||||
if (!("allowFallback" in normalized)) {
|
||||
normalized.allowFallback = true;
|
||||
}
|
||||
|
||||
@@ -9,13 +9,14 @@ require (
|
||||
github.com/go-flac/go-flac v1.0.0
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/ulikunitz/xz v0.5.15
|
||||
github.com/wailsapp/wails/v2 v2.11.0
|
||||
github.com/wailsapp/wails/v2 v2.12.0
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
golang.org/x/image v0.12.0
|
||||
golang.org/x/text v0.31.0
|
||||
)
|
||||
|
||||
require (
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
|
||||
@@ -73,8 +75,8 @@ github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+
|
||||
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
||||
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
|
||||
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"info": {
|
||||
"productName": "SpotiFLAC",
|
||||
"productVersion": "7.1.6",
|
||||
"productVersion": "7.1.7",
|
||||
"copyright": "© 2026 afkarxyz"
|
||||
},
|
||||
"wailsjsdir": "./frontend",
|
||||
|
||||
Reference in New Issue
Block a user