From b3ebef5ab9f44473bc8b6d9af99cde38c74e9e9a Mon Sep 17 00:00:00 2001 From: 429Enjoyer Date: Wed, 20 May 2026 05:53:45 +0700 Subject: [PATCH] v7.1.1 --- .github/FUNDING.yml | 3 +- README.md | 8 +- app.go | 361 +++++++-- backend/config.go | 137 +++- backend/provider_endpoints.go | 81 +- backend/qobuz.go | 597 +++++++++++--- backend/qobuz_providers.go | 106 +++ backend/tidal.go | 55 +- backend/tidal_api_list.go | 296 ------- frontend/src/App.tsx | 13 +- frontend/src/assets/patreon.svg | 6 + frontend/src/assets/patreon_symbol.svg | 11 + frontend/src/assets/x-pro.webp | Bin 15584 -> 0 bytes frontend/src/components/ApiStatusTab.tsx | 40 +- .../{AboutPage.tsx => OtherProjects.tsx} | 85 +- frontend/src/components/SettingsPage.tsx | 743 +++++++++--------- frontend/src/components/Sidebar.tsx | 20 +- frontend/src/components/SupportPage.tsx | 97 +++ frontend/src/components/TitleBar.tsx | 2 +- frontend/src/components/ui/badge-alert.tsx | 61 -- .../src/components/ui/bug-report-icon.tsx | 132 ++++ frontend/src/components/ui/github.tsx | 102 --- frontend/src/components/ui/tool-case.tsx | 89 +++ frontend/src/hooks/useApiStatus.ts | 4 +- frontend/src/hooks/useDownload.ts | 14 +- frontend/src/lib/api-status.ts | 296 +++++-- frontend/src/lib/settings.ts | 34 +- go.mod | 3 +- go.sum | 6 +- wails.json | 2 +- 30 files changed, 2147 insertions(+), 1257 deletions(-) create mode 100644 backend/qobuz_providers.go delete mode 100644 backend/tidal_api_list.go create mode 100644 frontend/src/assets/patreon.svg create mode 100644 frontend/src/assets/patreon_symbol.svg delete mode 100644 frontend/src/assets/x-pro.webp rename frontend/src/components/{AboutPage.tsx => OtherProjects.tsx} (78%) create mode 100644 frontend/src/components/SupportPage.tsx delete mode 100644 frontend/src/components/ui/badge-alert.tsx create mode 100644 frontend/src/components/ui/bug-report-icon.tsx delete mode 100644 frontend/src/components/ui/github.tsx create mode 100644 frontend/src/components/ui/tool-case.tsx diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 75fe6a9..1ee451b 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,2 @@ -ko_fi: afkarxyz \ No newline at end of file +ko_fi: afkarxyz +patreon: afkarxyz \ No newline at end of file diff --git a/README.md b/README.md index 161a9dc..7137312 100644 --- a/README.md +++ b/README.md @@ -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] > diff --git a/app.go b/app.go index 32c77d8..82a8e32 100644 --- a/app.go +++ b/app.go @@ -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,20 +692,15 @@ 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 + } + 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 { - 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) - } + 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 != "" { - return []string{fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL)} + if apiURL == "" { + return nil } - - 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 + return []string{fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL)} } 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())) { - return backend.CheckQobuzMusicDLStatus(client) + 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) { diff --git a/backend/config.go b/backend/config.go index 15a9dce..7da1973 100644 --- a/backend/config.go +++ b/backend/config.go @@ -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 { diff --git a/backend/provider_endpoints.go b/backend/provider_endpoints.go index 9261554..9c9ae9b 100644 --- a/backend/provider_endpoints.go +++ b/backend/provider_endpoints.go @@ -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 } diff --git a/backend/qobuz.go b/backend/qobuz.go index c11ee9e..0a6dea5 100644 --- a/backend/qobuz.go +++ b/backend/qobuz.go @@ -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 { - qualityInfo = fmt.Sprintf("Hi-Res (%d-bit / %.1f kHz)", track.MaximumBitDepth, track.MaximumSamplingRate) + 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) diff --git a/backend/qobuz_providers.go b/backend/qobuz_providers.go new file mode 100644 index 0000000..e00e835 --- /dev/null +++ b/backend/qobuz_providers.go @@ -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...) +} diff --git a/backend/tidal.go b/backend/tidal.go index 83148d0..fec2b64 100644 --- a/backend/tidal.go +++ b/backend/tidal.go @@ -50,26 +50,10 @@ 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 + return []string{customAPI}, nil } 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) { @@ -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") } diff --git a/backend/tidal_api_list.go b/backend/tidal_api_list.go deleted file mode 100644 index fd40804..0000000 --- a/backend/tidal_api_list.go +++ /dev/null @@ -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 -} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0a00d10..e71853c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ; case "debug": return ; - case "about": - return ; + case "projects": + return ; + case "support": + return ; case "history": return { metadata.loadFromCache(cachedData); diff --git a/frontend/src/assets/patreon.svg b/frontend/src/assets/patreon.svg new file mode 100644 index 0000000..5a0c330 --- /dev/null +++ b/frontend/src/assets/patreon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/assets/patreon_symbol.svg b/frontend/src/assets/patreon_symbol.svg new file mode 100644 index 0000000..f5d2997 --- /dev/null +++ b/frontend/src/assets/patreon_symbol.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/frontend/src/assets/x-pro.webp b/frontend/src/assets/x-pro.webp deleted file mode 100644 index ff896cb910ac7e8f911cd281888720a298e9ad64..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15584 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 V)kS$!o+l~RO?qk1f4?yU{Xb)H>f-; } if (status === "offline") { - return ; + return ; } return null; } @@ -19,9 +19,6 @@ function renderPlatformIcon(type: string) { if (type === "amazon") { return ; } - if (type === "musicbrainz") { - return ; - } if (type === "deezer") { return ; } @@ -31,27 +28,30 @@ function renderPlatformIcon(type: string) { return ; } 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 (
-

SpotiFLAC Services

+
+

SpotiFLAC

+ +
{sources.map((source) => { const status = statuses[source.id] || "idle"; - const isChecking = checkingSources[source.id] === true; return (
{renderPlatformIcon(source.type)}

{source.name}

-
{renderStatusIcon(status)}
+
{renderStatusIndicator(status)}
-
); })}
@@ -60,7 +60,13 @@ export function ApiStatusTab() {
-

SpotiFLAC Next Services

+
+

SpotiFLAC Next

+ +
{SPOTIFLAC_NEXT_SOURCES.map((source) => { @@ -70,7 +76,7 @@ export function ApiStatusTab() { {renderPlatformIcon(source.id)}

{source.name}

-
{renderStatusIcon(status)}
+
{renderStatusIndicator(status)}
); })}
diff --git a/frontend/src/components/AboutPage.tsx b/frontend/src/components/OtherProjects.tsx similarity index 78% rename from frontend/src/components/AboutPage.tsx rename to frontend/src/components/OtherProjects.tsx index 06b09f3..ba75fb0 100644 --- a/frontend/src/components/AboutPage.tsx +++ b/frontend/src/components/OtherProjects.tsx @@ -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>({}); - const [copiedUsdt, setCopiedUsdt] = useState(false); useEffect(() => { const fetchRepoStats = async () => { const CACHE_KEY = "github_repo_stats_v4"; @@ -181,24 +173,10 @@ export function AboutPage() { }; return (
-

About

+

Other Projects

-
- - -
- -
- - - {activeTab === "projects" && (
+
openExternal("https://github.com/spotbye/SpotiFLAC-Next")}> @@ -249,7 +227,7 @@ export function AboutPage() { Note

- 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.

)} @@ -313,7 +291,7 @@ export function AboutPage() { )}
- openExternal("https://exyezed.qzz.io/")}> + openExternal("https://exyezed.fyi/")}> Browser Extensions & Scripts @@ -339,55 +317,6 @@ export function AboutPage() {
-
)} - - {activeTab === "support" && (
-
- -
-
-
- Ko-fi -
-

Support via Ko-fi

-

- Enjoying the project? You can support ongoing development by buying me a coffee. -

-
- -
- - -
-
-
-
- USDT Barcode -
-
-

USDT (TRC20)

-

- Crypto donations are also accepted. Scan the QR code or copy the address. -

-
-
- - THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs - - -
-
-
-
)}
); } diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index 96077c3..d032be6 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -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 (

Settings

@@ -248,33 +257,27 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin General + - +
- {activeTab === "general" && (
+ {activeTab === "general" && (
-
- -
- setTempSettings((prev) => ({ - ...prev, - downloadPath: e.target.value, - }))} placeholder="C:\Users\YourUsername\Music"/> - -
-
-
+
+
@@ -357,6 +362,218 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
+
)} + + {activeTab === "download" && (
+
+
+ +
+ + {tempSettings.customTidalApi && ( + {tempSettings.customTidalApi} + )} +
+
+ +
+ +
+ + + {effectiveDownloader === "auto" && (<> + + + + )} + + {effectiveDownloader === "tidal" && ()} + + {effectiveDownloader === "qobuz" && ()} + + {effectiveDownloader === "amazon" && (
+ 16-bit - 24-bit/44.1kHz - 192kHz +
)} +
+ + {((effectiveDownloader === "tidal" && + tempSettings.tidalQuality === "HI_RES_LOSSLESS") || + (effectiveDownloader === "qobuz" && + tempSettings.qobuzQuality === "27") || + (effectiveDownloader === "auto" && + tempSettings.autoQuality === "24")) && (
+ setTempSettings((prev) => ({ + ...prev, + allowFallback: checked, + }))}/> + +
)} +
+
@@ -384,277 +601,37 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin +
+
-
- setTempSettings((prev) => ({ +
+ setTempSettings((prev) => ({ ...prev, allowResolverFallback: checked, }))}/> - -
-
-
- -
- -
- - - {tempSettings.downloader === "auto" && (<> - - - - )} - - {tempSettings.downloader === "tidal" && ()} - - {tempSettings.downloader === "qobuz" && ()} - - {tempSettings.downloader === "amazon" && (
- 16-bit - 24-bit/44.1kHz - 192kHz -
)} - -
- - {((tempSettings.downloader === "tidal" && - tempSettings.tidalQuality === "HI_RES_LOSSLESS") || - (tempSettings.downloader === "qobuz" && - tempSettings.qobuzQuality === "27") || - (tempSettings.downloader === "auto" && - tempSettings.autoQuality === "24")) && (
- setTempSettings((prev) => ({ - ...prev, - allowFallback: checked, - }))}/> - -
)} - - {(tempSettings.downloader === "auto" || tempSettings.downloader === "tidal") && (
- -
- - {tempSettings.customTidalApi && ( - {tempSettings.customTidalApi} - )} -
-
)} -
- -
- -
-
- setTempSettings((prev) => ({ - ...prev, - embedMaxQualityCover: checked, - }))}/> - -
-
- setTempSettings((prev) => ({ - ...prev, - embedGenre: checked, - }))}/> - -
- {tempSettings.embedGenre && (
- setTempSettings((prev) => ({ - ...prev, - useSingleGenre: checked, - }))}/> - -
)} -
- setTempSettings((prev) => ({ - ...prev, - embedLyrics: checked, - }))}/> - -
+
)} - {activeTab === "files" && (
-
+ {activeTab === "files" && (
+
+
+ +
+ setTempSettings((prev) => ({ + ...prev, + downloadPath: e.target.value, + }))} placeholder="C:\Users\YourUsername\Music"/> + +
+
+
@@ -742,31 +719,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin Create M3U8 Playlist File
- -
- setTempSettings((prev) => ({ - ...prev, - useFirstArtistOnly: checked, - }))}/> - -
- -
- setTempSettings((prev) => ({ - ...prev, - redownloadWithSuffix: checked, - }))}/> - -
- -
-
+
{ +
+ + + + + + +

+ Variables:{" "} + {TEMPLATE_VARIABLES.map((v) => v.key).join(", ")} +

+
+
+
+
+ - {tempSettings.filenamePreset === "custom" && ( setTempSettings((prev) => ({ - ...prev, - filenameTemplate: e.target.value, - }))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)} -
-
- -
- + {tempSettings.filenamePreset === "custom" && ( setTempSettings((prev) => ({ + ...prev, + filenameTemplate: e.target.value, + }))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)}
-
- - {tempSettings.filenameTemplate && (

- Preview:{" "} - - {tempSettings.filenameTemplate + {tempSettings.filenameTemplate && (

+ Preview:{" "} + + {tempSettings.filenameTemplate .replace(/\{artist\}/g, tempSettings.separator === "comma" ? "Kendrick Lamar, SZA" : "Kendrick Lamar; SZA") .replace(/\{album_artist\}/g, "Kendrick Lamar") .replace(/\{album\}/g, "Black Panther") @@ -858,10 +795,92 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin

)}
+ +
+ + +
+ +
+ setTempSettings((prev) => ({ + ...prev, + redownloadWithSuffix: checked, + }))}/> + +
)} - - {activeTab === "api" && ()} + + {activeTab === "metadata" && (
+
+
+ setTempSettings((prev) => ({ + ...prev, + embedLyrics: checked, + }))}/> + +
+ +
+ setTempSettings((prev) => ({ + ...prev, + embedMaxQualityCover: checked, + }))}/> + +
+ +
+ setTempSettings((prev) => ({ + ...prev, + embedGenre: checked, + }))}/> + +
+ + {tempSettings.embedGenre && (
+ setTempSettings((prev) => ({ + ...prev, + useSingleGenre: checked, + }))}/> + +
)} +
+ +
+
+ setTempSettings((prev) => ({ + ...prev, + useFirstArtistOnly: checked, + }))}/> + +
+
+
)} + + {activeTab === "status" && ()}
open ? setShowAddFontDialog(true) : closeAddFontDialog()}> @@ -915,7 +934,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
- Custom Instance + Tidal Source {tempSettings.customTidalApi && ( @@ -134,7 +134,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) { @@ -176,23 +176,23 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) { - -

About

+

Other Projects

- -

Support me on Ko-fi

+

Support Me

diff --git a/frontend/src/components/SupportPage.tsx b/frontend/src/components/SupportPage.tsx new file mode 100644 index 0000000..3811e9e --- /dev/null +++ b/frontend/src/components/SupportPage.tsx @@ -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 (
+
+

Support Me

+
+ +
+
+
+
+
+ Ko-fi +
+

Support via Ko-fi

+

+ Buy me a coffee to help keep development going. +

+
+ +
+ +
+
+
+ Patreon +
+

Support via Patreon

+

+ Join on Patreon to help fund the project and follow updates. +

+
+ +
+ +
+
+
+
+ USDT Barcode +
+
+

USDT (TRC20)

+

+ Prefer crypto? Use the QR code or wallet address below. +

+
+
+ + THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs + + +
+
+
+ +
+ If you have any questions or need help with donating, feel free to reach out via{" "} + {" "} + or{" "} + + . +
+
+
); +} diff --git a/frontend/src/components/TitleBar.tsx b/frontend/src/components/TitleBar.tsx index 0727696..992ebda 100644 --- a/frontend/src/components/TitleBar.tsx +++ b/frontend/src/components/TitleBar.tsx @@ -176,7 +176,7 @@ export function TitleBar() {
)}
- openExternal("https://afkarxyz.qzz.io")} className="gap-2"> + openExternal("https://afkarxyz.fyi")} className="gap-2"> Website diff --git a/frontend/src/components/ui/badge-alert.tsx b/frontend/src/components/ui/badge-alert.tsx deleted file mode 100644 index 609bfed..0000000 --- a/frontend/src/components/ui/badge-alert.tsx +++ /dev/null @@ -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 { - 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(({ 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) => { - if (isControlledRef.current) { - onMouseEnter?.(e); - } - else { - controls.start("animate"); - } - }, [controls, onMouseEnter]); - const handleMouseLeave = useCallback((e: React.MouseEvent) => { - if (isControlledRef.current) { - onMouseLeave?.(e); - } - else { - controls.start("normal"); - } - }, [controls, onMouseLeave]); - return (
- - - - - -
); -}); -BadgeAlertIcon.displayName = "BadgeAlertIcon"; -export { BadgeAlertIcon }; diff --git a/frontend/src/components/ui/bug-report-icon.tsx b/frontend/src/components/ui/bug-report-icon.tsx new file mode 100644 index 0000000..463f9ff --- /dev/null +++ b/frontend/src/components/ui/bug-report-icon.tsx @@ -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 { + 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 (<> + + + + + + + + + + + + ); +} + +function BulbPaths() { + return (<> + + + + ); +} + +function ReportIconGroup({ mode }: { mode: ReportIconMode }) { + return ( + {mode === "bug" ? : } + ); +} + +function StaticBugIcon() { + return ( + + + + + + + + + + + + ); +} + +function BugReportIcon({ className, size = 28, loop = false, ...props }: BugReportIconProps) { + const [mode, setMode] = useState("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 (
+ + {loop ? ( + + ) : ()} + +
); +} + +export { BugReportIcon }; diff --git a/frontend/src/components/ui/github.tsx b/frontend/src/components/ui/github.tsx deleted file mode 100644 index 87f2676..0000000 --- a/frontend/src/components/ui/github.tsx +++ /dev/null @@ -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 { - 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(({ 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) => { - 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) => { - if (isControlledRef.current) { - onMouseLeave?.(e); - } - else { - bodyControls.start("normal"); - tailControls.start("normal"); - } - }, [bodyControls, tailControls, onMouseLeave]); - return (
- - - - -
); -}); -GithubIcon.displayName = "GithubIcon"; -export { GithubIcon }; diff --git a/frontend/src/components/ui/tool-case.tsx b/frontend/src/components/ui/tool-case.tsx new file mode 100644 index 0000000..9b5f79b --- /dev/null +++ b/frontend/src/components/ui/tool-case.tsx @@ -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 { + 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(({ 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) => { + if (!isControlledRef.current) { + controls.start('animate'); + } + else { + onMouseEnter?.(e); + } + }, [controls, onMouseEnter]); + + const handleMouseLeave = useCallback((e: React.MouseEvent) => { + if (!isControlledRef.current) { + controls.start('normal'); + } + else { + onMouseLeave?.(e); + } + }, [controls, onMouseLeave]); + + return (
+ + + + + + +
); +}); + +ToolCaseIcon.displayName = 'ToolCaseIcon'; + +export { ToolCaseIcon }; diff --git a/frontend/src/hooks/useApiStatus.ts b/frontend/src/hooks/useApiStatus.ts index b311a45..81d6f63 100644 --- a/frontend/src/hooks/useApiStatus.ts +++ b/frontend/src/hooks/useApiStatus.ts @@ -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(), }; } diff --git a/frontend/src/hooks/useDownload.ts b/frontend/src/hooks/useDownload.ts index 8c96e7b..09c8fd1 100644 --- a/frontend/src/hooks/useDownload.ts +++ b/frontend/src/hooks/useDownload.ts @@ -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 { diff --git a/frontend/src/lib/api-status.ts b/frontend/src/lib/api-status.ts index cd6f78f..2033802 100644 --- a/frontend/src/lib/api-status.ts +++ b/frontend/src/lib/api-status.ts @@ -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>; +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 => (window as any)["go"]["main"]["App"]["CheckAPIStatusReport"](apiType, apiURL); +const LogStatusConsole = (level: string, message: string): Promise => (window as any)["go"]["main"]["App"]["LogStatusConsole"](level, message); + type ApiStatusState = { checkingSources: Record; statuses: Record; nextStatuses: Record; }; + let apiStatusState: ApiStatusState = { checkingSources: {}, statuses: {}, nextStatuses: {}, }; + +let activeCheckCurrentOnly: Promise | null = null; let activeCheckNextOnly: Promise | null = null; +let activeStatusPayloadFetch: Promise | null = null; + const activeSourceChecks = new Map>(); 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 { + +function delay(ms: number): Promise { + 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): 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 { - 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): Record { return SPOTIFLAC_NEXT_SOURCES.reduce>((acc, source) => { const current = currentStatuses[source.id]; @@ -90,57 +168,142 @@ function getSafeNextStatusesFallback(currentStatuses: Record> { - 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>((acc, source) => { - acc[source.id] = anyNextVariantUp(getNextSourceValues(payload, source)); - return acc; - }, {}); -} -async function checkSpotiFLACNextStatuses(): Promise> { - 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 { + 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 { + 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 { + 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> { + const payload = await fetchSpotiFLACStatusPayload(); + return SPOTIFLAC_NEXT_SOURCES.reduce>((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 { + 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 { 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 { ...checkingNextStatuses, }, })); + try { - setApiStatusState((current) => ({ - ...current, - nextStatuses: { ...current.nextStatuses }, - })); const nextStatuses = await checkSpotiFLACNextStatuses(); setApiStatusState((current) => ({ ...current, @@ -170,26 +330,40 @@ export async function checkSpotiFLACNextStatusesOnly(): Promise { nextStatuses: getSafeNextStatusesFallback(current.nextStatuses), })); } - finally { - activeCheckNextOnly = null; - } })(); - return activeCheckNextOnly; + + try { + await activeCheckNextOnly; + } + finally { + activeCheckNextOnly = null; + } } -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 { 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 { [sourceId]: "checking", }, })); + try { const status = await checkSourceStatus(source); setApiStatusState((current) => ({ @@ -223,6 +398,7 @@ export async function checkApiStatus(sourceId: string): Promise { activeSourceChecks.delete(sourceId); } })(); + activeSourceChecks.set(sourceId, task); return task; } diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index 306b58e..f6fc05a 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -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; } diff --git a/go.mod b/go.mod index 6795b5d..69e8f4b 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 58a355f..fbb24c8 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/wails.json b/wails.json index 50a1c38..d7ef05b 100644 --- a/wails.json +++ b/wails.json @@ -12,7 +12,7 @@ }, "info": { "productName": "SpotiFLAC", - "productVersion": "7.1.6", + "productVersion": "7.1.7", "copyright": "© 2026 afkarxyz" }, "wailsjsdir": "./frontend",