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 ff896cb..0000000 Binary files a/frontend/src/assets/x-pro.webp and /dev/null differ diff --git a/frontend/src/components/ApiStatusTab.tsx b/frontend/src/components/ApiStatusTab.tsx index 3314b28..e451746 100644 --- a/frontend/src/components/ApiStatusTab.tsx +++ b/frontend/src/components/ApiStatusTab.tsx @@ -1,14 +1,14 @@ import { Button } from "@/components/ui/button"; -import { SearchCheck, CheckCircle2, XCircle, Loader2 } from "lucide-react"; -import { TidalIcon, QobuzIcon, AmazonIcon, MusicBrainzIcon, AppleMusicIcon, DeezerIcon } from "./PlatformIcons"; +import { PlugZap, CheckCircle2, Loader2, Wrench } from "lucide-react"; +import { TidalIcon, QobuzIcon, AmazonIcon, AppleMusicIcon, DeezerIcon } from "./PlatformIcons"; import { useApiStatus } from "@/hooks/useApiStatus"; import { SPOTIFLAC_NEXT_SOURCES } from "@/lib/api-status"; -function renderStatusIcon(status: "checking" | "online" | "offline" | "idle") { +function renderStatusIndicator(status: "checking" | "online" | "offline" | "idle") { if (status === "online") { return ; } 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",