This commit is contained in:
429Enjoyer
2026-05-20 05:53:45 +07:00
parent 0093df6016
commit b3ebef5ab9
30 changed files with 2147 additions and 1257 deletions
+1
View File
@@ -1 +1,2 @@
ko_fi: afkarxyz ko_fi: afkarxyz
patreon: afkarxyz
+3 -5
View File
@@ -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. Download Spotify Tracks, Albums, Playlists & Discography as MP3/OGG/Opus.
## Related projects
### [SpotiFLAC (Mobile)](https://github.com/zarzet/SpotiFLAC-Mobile) ### [SpotiFLAC (Mobile)](https://github.com/zarzet/SpotiFLAC-Mobile)
SpotiFLAC for Android & iOS — maintained by [@zarzet](https://github.com/zarzet) SpotiFLAC for Android & iOS — maintained by [@zarzet](https://github.com/zarzet)
### [SpotiFLAC (CLI)](https://github.com/Nizarberyan/SpotiFLAC)
SpotiFLAC for command-line environments — maintained by [@Nizarberyan](https://github.com/Nizarberyan)
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version) ### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
SpotiFLAC Python library for SpotiFLAC integration — maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu) 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 ## 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] > [!TIP]
> >
+291 -56
View File
@@ -33,12 +33,41 @@ type CurrentIPInfo struct {
Source string `json:"source,omitempty"` 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 const checkOperationTimeout = 10 * time.Second
func NewApp() *App { func NewApp() *App {
return &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 { type timedResult[T any] struct {
value T value T
err error err error
@@ -276,11 +305,12 @@ func (a *App) startup(ctx context.Context) {
if err := backend.InitProviderPriorityDB(); err != nil { if err := backend.InitProviderPriorityDB(); err != nil {
fmt.Printf("Failed to init provider priority DB: %v\n", err) fmt.Printf("Failed to init provider priority DB: %v\n", err)
} }
go func() { if err := backend.CleanupLegacyTidalPublicAPIState(); err != nil {
if err := backend.PrimeTidalAPIList(); err != nil { fmt.Printf("Failed to clean legacy Tidal API cache: %v\n", err)
fmt.Printf("Failed to prime Tidal API list: %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) { func (a *App) shutdown(ctx context.Context) {
@@ -662,21 +692,16 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
} }
case "tidal": case "tidal":
if req.TidalAPIURL == "" || req.TidalAPIURL == "auto" { if !strings.HasPrefix(strings.TrimRight(strings.TrimSpace(req.TidalAPIURL), "/"), "https://") {
downloader := backend.NewTidalDownloader("") err = fmt.Errorf("a configured HTTPS Tidal instance is required")
if req.ServiceURL != "" { break
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)
} }
} else {
downloader := backend.NewTidalDownloader(req.TidalAPIURL) downloader := backend.NewTidalDownloader(req.TidalAPIURL)
if req.ServiceURL != "" { if req.ServiceURL != "" {
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) 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 { } 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": case "qobuz":
@@ -986,15 +1011,7 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
isOnline, err := runWithTimeout(checkOperationTimeout, func() (bool, error) { isOnline, err := runWithTimeout(checkOperationTimeout, func() (bool, error) {
switch apiType { switch apiType {
case "tidal": case "tidal":
if checkGroupedAPIStatus("tidal", buildTidalStatusCheckURLs(apiURL)) { return checkGroupedAPIStatus("tidal", buildTidalStatusCheckURLs(apiURL)), nil
return true, nil
}
if strings.TrimSpace(apiURL) == "" {
if _, refreshErr := backend.RefreshTidalAPIList(true); refreshErr == nil && checkGroupedAPIStatus("tidal", buildTidalStatusCheckURLs("")) {
return true, nil
}
}
return false, nil
case "qobuz", "qbz": case "qobuz", "qbz":
return checkGroupedAPIStatus("qobuz", buildQobuzStatusCheckURLs(apiURL)), nil return checkGroupedAPIStatus("qobuz", buildQobuzStatusCheckURLs(apiURL)), nil
case "amazon": case "amazon":
@@ -1022,6 +1039,39 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
return isOnline 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 { func (a *App) CheckCustomTidalAPI(apiURL string) bool {
type tidalProbeResponse struct { type tidalProbeResponse struct {
Version string `json:"version"` Version string `json:"version"`
@@ -1108,46 +1158,18 @@ func (a *App) CheckCustomTidalAPI(apiURL string) bool {
func buildTidalStatusCheckURLs(apiURL string) []string { func buildTidalStatusCheckURLs(apiURL string) []string {
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/") apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
if apiURL != "" { if apiURL == "" {
return nil
}
return []string{fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL)} return []string{fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL)}
} }
apis, err := backend.GetRotatedTidalAPIList()
if err != nil {
fmt.Printf("Warning: failed to load rotated Tidal API list for status check: %v\n", err)
}
urls := make([]string, 0, len(apis))
for _, baseURL := range apis {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
continue
}
urls = append(urls, fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", baseURL))
}
return urls
}
func buildQobuzStatusCheckURLs(apiURL string) []string { func buildQobuzStatusCheckURLs(apiURL string) []string {
if trimmed := strings.TrimSpace(apiURL); trimmed != "" { if trimmed := strings.TrimSpace(apiURL); trimmed != "" {
return []string{buildQobuzStatusCheckURL(trimmed)} return []string{trimmed}
} }
bases := backend.GetQobuzStreamAPIBaseURLs() return backend.GetQobuzDownloadProviderURLs()
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)
} }
func buildAmazonStatusCheckURLs(apiURL string) []string { func buildAmazonStatusCheckURLs(apiURL string) []string {
@@ -1213,10 +1235,222 @@ func checkGroupedAPIStatus(apiType string, checkURLs []string) bool {
return false 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 { func checkSingleAPIStatus(apiType string, checkURL string) bool {
client := &http.Client{Timeout: 4 * time.Second} client := &http.Client{Timeout: 4 * time.Second}
if (apiType == "qobuz" || apiType == "qbz") && strings.EqualFold(strings.TrimSpace(checkURL), strings.TrimSpace(backend.GetQobuzMusicDLDownloadAPIURL())) { if apiType == "qobuz" || apiType == "qbz" {
switch {
case backend.IsQobuzWJHEProviderURL(checkURL):
return backend.CheckQobuzWJHEStatus(client)
case backend.IsQobuzMusicDLProviderURL(checkURL):
return backend.CheckQobuzMusicDLStatus(client) return backend.CheckQobuzMusicDLStatus(client)
case backend.IsQobuzGDStudioProviderURL(checkURL):
return backend.CheckQobuzGDStudioAPIStatus(client, checkURL)
}
} }
req, err := backend.NewRequestWithDefaultHeaders(http.MethodGet, checkURL, nil) req, err := backend.NewRequestWithDefaultHeaders(http.MethodGet, checkURL, nil)
@@ -2045,6 +2279,7 @@ func (a *App) SaveSettings(settings map[string]interface{}) error {
if err != nil { if err != nil {
return err return err
} }
settings = backend.SanitizeSettingsMap(settings)
dir := filepath.Dir(configPath) dir := filepath.Dir(configPath)
if _, err := os.Stat(dir); os.IsNotExist(err) { if _, err := os.Stat(dir); os.IsNotExist(err) {
@@ -2102,7 +2337,7 @@ func (a *App) LoadSettings() (map[string]interface{}, error) {
return nil, err return nil, err
} }
return settings, nil return backend.SanitizeSettingsMap(settings), nil
} }
func (a *App) LoadFonts() ([]map[string]interface{}, error) { func (a *App) LoadFonts() ([]map[string]interface{}, error) {
+129 -8
View File
@@ -2,11 +2,138 @@ package backend
import ( import (
"encoding/json" "encoding/json"
"errors"
"os" "os"
"path/filepath" "path/filepath"
"strings" "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 { func GetDefaultMusicPath() string {
homeDir, err := os.UserHomeDir() homeDir, err := os.UserHomeDir()
@@ -47,7 +174,7 @@ func LoadConfigSettings() (map[string]interface{}, error) {
return nil, err return nil, err
} }
return settings, nil return SanitizeSettingsMap(settings), nil
} }
func GetRedownloadWithSuffixSetting() bool { func GetRedownloadWithSuffixSetting() bool {
@@ -66,13 +193,7 @@ func GetCustomTidalAPISetting() string {
return "" return ""
} }
customAPI, _ := settings["customTidalApi"].(string) return normalizeCustomTidalAPIValue(settings["customTidalApi"])
customAPI = strings.TrimRight(strings.TrimSpace(customAPI), "/")
if strings.HasPrefix(customAPI, "https://") {
return customAPI
}
return ""
} }
func normalizeExistingFileCheckMode(value string) string { func normalizeExistingFileCheckMode(value string) string {
+74 -7
View File
@@ -1,21 +1,88 @@
package backend package backend
const amazonMusicAPIBaseURL = "https://amazon.spotbye.qzz.io" import (
const qobuzMusicDLDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download" "net/url"
"strings"
)
var defaultQobuzStreamAPIBaseURLs = []string{ const amazonMusicAPIBaseURL = "https://amazon.spotbye.qzz.io"
"https://dab.yeet.su/api/stream?trackId=",
"https://dabmusic.xyz/api/stream?trackId=", 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 { func GetQobuzDownloadProviderURLs() []string {
return append([]string(nil), defaultQobuzStreamAPIBaseURLs...) return append([]string(nil), defaultQobuzDownloadProviderURLs...)
}
func GetQobuzWJHESearchAPIURL() string {
return qobuzWJHESearchAPIURL
}
func GetQobuzWJHEStreamAPIURL() string {
return qobuzWJHEStreamAPIURL
} }
func GetQobuzMusicDLDownloadAPIURL() string { func GetQobuzMusicDLDownloadAPIURL() string {
return qobuzMusicDLDownloadAPIURL 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 { func GetAmazonMusicAPIBaseURL() string {
return amazonMusicAPIBaseURL return amazonMusicAPIBaseURL
} }
+475 -120
View File
@@ -4,7 +4,9 @@ import (
"bytes" "bytes"
"crypto/aes" "crypto/aes"
"crypto/cipher" "crypto/cipher"
"crypto/md5"
"crypto/sha256" "crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -13,6 +15,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -20,17 +23,6 @@ import (
type QobuzDownloader struct { type QobuzDownloader struct {
client *http.Client 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 { type QobuzTrack struct {
@@ -69,10 +61,6 @@ type QobuzTrack struct {
} `json:"album"` } `json:"album"`
} }
type QobuzStreamResponse struct {
URL string `json:"url"`
}
type qobuzMusicDLRequest struct { type qobuzMusicDLRequest struct {
URL string `json:"url"` URL string `json:"url"`
Quality string `json:"quality"` Quality string `json:"quality"`
@@ -89,12 +77,20 @@ type qobuzMusicDLResponse struct {
Error string `json:"error"` 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 ( var (
qobuzMusicDLDebugKeyOnce sync.Once qobuzMusicDLDebugKeyOnce sync.Once
qobuzMusicDLDebugKey string qobuzMusicDLDebugKey string
qobuzMusicDLDebugKeyErr error qobuzMusicDLDebugKeyErr error
qobuzStreamingURLPattern = regexp.MustCompile(`https?://[^\s"'<>\\)]+`)
) )
var qobuzMusicDLDebugKeySeedParts = [][]byte{ var qobuzMusicDLDebugKeySeedParts = [][]byte{
@@ -129,7 +125,6 @@ func NewQobuzDownloader() *QobuzDownloader {
client: &http.Client{ client: &http.Client{
Timeout: 60 * time.Second, Timeout: 60 * time.Second,
}, },
appID: qobuzDefaultAPIAppID,
} }
} }
@@ -184,112 +179,464 @@ func getQobuzMusicDLDebugKey() (string, error) {
return qobuzMusicDLDebugKey, nil 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_") { 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) resp, err := doQobuzSignedRequest(http.MethodGet, "track/get", url.Values{"track_id": {trackID}}, q.client)
if err != nil { 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() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { 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 var trackResp QobuzTrack
if err := json.NewDecoder(resp.Body).Decode(&trackResp); err != nil { 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 return &trackResp, nil
} }
resp, err := doQobuzSignedRequest(http.MethodGet, "track/search", url.Values{ queries := []string{strings.TrimSpace(isrc)}
"query": {isrc}, if fallbackQuery := strings.TrimSpace(strings.Join([]string{spotifyTrackName, spotifyArtistName}, " ")); fallbackQuery != "" {
"limit": {"1"}, queries = append(queries, fallbackQuery)
}, q.client) }
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 { 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() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if location := strings.TrimSpace(resp.Header.Get("Location")); qobuzURLLooksStreamable(location) {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode) return location, nil
} }
var searchResp QobuzSearchResponse body, err := io.ReadAll(io.LimitReader(resp.Body, 128*1024))
body, err := io.ReadAll(resp.Body)
if err != nil { 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 { if streamURL := extractQobuzStreamingURL(body); streamURL != "" {
return nil, fmt.Errorf("API returned empty response") return streamURL, nil
} }
if err := json.Unmarshal(body, &searchResp); err != nil { if resp.Request != nil && resp.Request.URL != nil {
if streamURL := strings.TrimSpace(resp.Request.URL.String()); streamURL != "" && streamURL != apiURL && qobuzURLLooksStreamable(streamURL) {
bodyStr := string(body) return streamURL, nil
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
} }
return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
} }
if len(searchResp.Tracks.Items) == 0 { if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
return nil, fmt.Errorf("track not found for ISRC: %s", isrc) 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 { func qobuzGDStudioPaddedVersion() string {
return fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality) 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) { func qobuzGDStudioEscapedValue(value string) string {
apiURL := buildQobuzAPIURL(apiBase, trackID, quality) return strings.ReplaceAll(url.QueryEscape(strings.TrimSpace(value)), "+", "%20")
req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil) }
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 { 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) resp, err := q.client.Do(req)
if err != nil { if err != nil {
return "", err return "", fmt.Errorf("failed to reach GDStudio: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
return "", fmt.Errorf("status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return "", err return "", fmt.Errorf("failed to read GDStudio response: %w", err)
} }
if len(body) == 0 { if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("empty body") return "", fmt.Errorf("GDStudio returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
} }
var streamResp QobuzStreamResponse streamURL := extractQobuzStreamingURL(body)
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" { if streamURL == "" {
return streamResp.URL, nil return "", fmt.Errorf("GDStudio response did not include a stream URL: %s", previewQobuzResponseBody(body, 256))
} }
var nestedResp struct { return streamURL, nil
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")
} }
func (q *QobuzDownloader) DownloadFromMusicDL(trackID int64, quality string) (string, error) { 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 return downloadURL, nil
} }
func CheckQobuzMusicDLStatus(client *http.Client) bool { func CheckQobuzMusicDLStatusDetailed(client *http.Client) error {
if client == nil { if client == nil {
client = &http.Client{Timeout: 4 * time.Second} client = &http.Client{Timeout: 4 * time.Second}
} }
downloader := &QobuzDownloader{client: client, appID: qobuzDefaultAPIAppID} downloader := &QobuzDownloader{client: client}
_, err := downloader.DownloadFromMusicDL(qobuzMusicDLProbeTrackID, "27") _, err := downloader.DownloadFromMusicDL(qobuzProbeTrackID, "27")
return err == nil 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) { 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) fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
downloadFunc := func(qual string) (string, error) { downloadFunc := func(qual string) (string, error) {
type Provider struct { attemptMap := make(map[string]qobuzProviderAttempt)
Name string attemptIDs := make([]string, 0, len(GetQobuzDownloadProviderURLs()))
API string for _, provider := range q.getQobuzDownloadProviders() {
Func func() (string, error) for _, attempt := range provider.Attempts(trackID, qual) {
} attemptMap[attempt.ID] = attempt
attemptIDs = append(attemptIDs, attempt.ID)
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)
},
} }
} }
orderedProviderIDs := prioritizeProviders("qobuz", providerIDs) orderedProviderIDs := prioritizeProviders("qobuz", attemptIDs)
primaryProviderID := GetQobuzMusicDLDownloadAPIURL() orderedProviderIDs = moveQobuzAttemptIDsLast(orderedProviderIDs, 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
}
var lastErr error var lastErr error
for _, providerID := range orderedProviderIDs { for _, providerID := range orderedProviderIDs {
p, ok := providerMap[providerID] attempt, ok := attemptMap[providerID]
if !ok { if !ok {
continue 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 { if err == nil {
fmt.Printf("✓ Success\n") fmt.Printf("✓ Success\n")
recordProviderSuccess("qobuz", p.API) recordProviderSuccess("qobuz", attempt.ID)
return url, nil return url, nil
} }
fmt.Printf("Provider failed: %v\n", err) fmt.Printf("Provider failed: %v\n", err)
recordProviderFailure("qobuz", p.API) recordProviderFailure("qobuz", attempt.ID)
lastErr = err lastErr = err
} }
return "", lastErr 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 { if err != nil {
return "", err return "", err
} }
@@ -661,7 +1010,13 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena
qualityInfo := "Standard" qualityInfo := "Standard"
if track.Hires { if track.Hires {
if track.MaximumBitDepth > 0 && track.MaximumSamplingRate > 0 {
qualityInfo = fmt.Sprintf("Hi-Res (%d-bit / %.1f kHz)", track.MaximumBitDepth, track.MaximumSamplingRate) 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) fmt.Printf("Quality: %s\n", qualityInfo)
+106
View File
@@ -0,0 +1,106 @@
package backend
type qobuzDownloadProvider interface {
Name() string
Attempts(trackID int64, quality string) []qobuzProviderAttempt
}
type qobuzProviderAttempt struct {
Name string
ID string
Download func() (string, error)
}
type QobuzProviderWJHE struct {
downloader *QobuzDownloader
}
func (p QobuzProviderWJHE) Name() string {
return "QobuzProviderWJHE"
}
func (p QobuzProviderWJHE) Attempts(trackID int64, quality string) []qobuzProviderAttempt {
return []qobuzProviderAttempt{
{
Name: p.Name(),
ID: GetQobuzWJHEStreamAPIURL(),
Download: func() (string, error) {
return p.downloader.DownloadFromWJHE(trackID, quality)
},
},
}
}
type QobuzProviderMusicDL struct {
downloader *QobuzDownloader
}
func (p QobuzProviderMusicDL) Name() string {
return "QobuzProviderMusicDL"
}
func (p QobuzProviderMusicDL) Attempts(trackID int64, quality string) []qobuzProviderAttempt {
return []qobuzProviderAttempt{
{
Name: p.Name(),
ID: GetQobuzMusicDLDownloadAPIURL(),
Download: func() (string, error) {
return p.downloader.DownloadFromMusicDL(trackID, quality)
},
},
}
}
type QobuzProviderGDStudio struct {
downloader *QobuzDownloader
}
func (p QobuzProviderGDStudio) Name() string {
return "QobuzProviderGDStudio"
}
func (p QobuzProviderGDStudio) Attempts(trackID int64, quality string) []qobuzProviderAttempt {
attempts := make([]qobuzProviderAttempt, 0, len(GetQobuzGDStudioAPIURLs()))
for _, apiURL := range GetQobuzGDStudioAPIURLs() {
currentAPIURL := apiURL
attempts = append(attempts, qobuzProviderAttempt{
Name: p.Name(),
ID: currentAPIURL,
Download: func() (string, error) {
return p.downloader.DownloadFromGDStudio(trackID, quality, currentAPIURL)
},
})
}
return attempts
}
func (q *QobuzDownloader) getQobuzDownloadProviders() []qobuzDownloadProvider {
return []qobuzDownloadProvider{
QobuzProviderWJHE{downloader: q},
QobuzProviderGDStudio{downloader: q},
QobuzProviderMusicDL{downloader: q},
}
}
func moveQobuzAttemptIDsLast(providerIDs []string, lastIDs ...string) []string {
if len(providerIDs) == 0 || len(lastIDs) == 0 {
return append([]string(nil), providerIDs...)
}
lastIDSet := make(map[string]struct{}, len(lastIDs))
for _, providerID := range lastIDs {
lastIDSet[providerID] = struct{}{}
}
ordered := make([]string, 0, len(providerIDs))
trailing := make([]string, 0, len(providerIDs))
for _, providerID := range providerIDs {
if _, ok := lastIDSet[providerID]; ok {
trailing = append(trailing, providerID)
continue
}
ordered = append(ordered, providerID)
}
return append(ordered, trailing...)
}
+7 -46
View File
@@ -50,28 +50,12 @@ type TidalBTSManifest struct {
func getConfiguredTidalAPIAttemptList() ([]string, error) { func getConfiguredTidalAPIAttemptList() ([]string, error) {
customAPI := GetCustomTidalAPISetting() customAPI := GetCustomTidalAPISetting()
apis, err := GetRotatedTidalAPIList()
if customAPI == "" { 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 return []string{customAPI}, nil
} }
result := make([]string, 0, len(apis)+1)
result = append(result, customAPI)
for _, apiURL := range apis {
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
if apiURL == "" || apiURL == customAPI {
continue
}
result = append(result, apiURL)
}
return result, err
}
func buildTidalOutputPath(outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyTrackNumber, spotifyDiscNumber int, isrcOverride string, useFirstArtistOnly bool) (string, bool, error) { func buildTidalOutputPath(outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyTrackNumber, spotifyDiscNumber int, isrcOverride string, useFirstArtistOnly bool) (string, bool, error) {
if outputDir != "." { if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil { if err := os.MkdirAll(outputDir, 0755); err != nil {
@@ -212,13 +196,6 @@ func finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName,
func NewTidalDownloader(apiURL string) *TidalDownloader { func NewTidalDownloader(apiURL string) *TidalDownloader {
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/") apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
if apiURL == "" {
apis, err := getConfiguredTidalAPIAttemptList()
if err == nil && len(apis) > 0 {
apiURL = apis[0]
}
}
return &TidalDownloader{ return &TidalDownloader{
client: &http.Client{ client: &http.Client{
Timeout: 5 * time.Second, 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) { func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
fmt.Println("Fetching URL...") 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) url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality)
fmt.Printf("Tidal API URL: %s\n", url) fmt.Printf("Tidal API URL: %s\n", url)
@@ -606,11 +586,6 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
cleanupTidalDownloadArtifacts(outputFilename) cleanupTidalDownloadArtifacts(outputFilename)
return outputFilename, err 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) 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) return "", fmt.Errorf("songlink/songstats couldn't find Tidal URL: %w", err)
} }
if t.apiURL != "" { 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) return "", fmt.Errorf("no configured custom tidal api instance")
} }
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)
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)
} }
type SegmentTemplate struct { type SegmentTemplate struct {
@@ -892,22 +866,9 @@ func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilena
continue continue
} }
if err := RememberTidalAPIUsage(apiURL); err != nil {
fmt.Printf("Warning: failed to persist last used Tidal API: %v\n", err)
}
return apiURL, nil 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 { if lastErr == nil {
lastErr = fmt.Errorf("all tidal apis failed") lastErr = fmt.Errorf("all tidal apis failed")
} }
-296
View File
@@ -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
}
+8 -5
View File
@@ -24,15 +24,16 @@ import { AudioResamplerPage } from "@/components/AudioResamplerPage";
import { FileManagerPage } from "@/components/FileManagerPage"; import { FileManagerPage } from "@/components/FileManagerPage";
import { SettingsPage } from "@/components/SettingsPage"; import { SettingsPage } from "@/components/SettingsPage";
import { DebugLoggerPage } from "@/components/DebugLoggerPage"; import { DebugLoggerPage } from "@/components/DebugLoggerPage";
import { AboutPage } from "@/components/AboutPage"; import { OtherProjects } from "@/components/OtherProjects";
import { HistoryPage } from "@/components/HistoryPage"; import { HistoryPage } from "@/components/HistoryPage";
import { SupportPage } from "@/components/SupportPage";
import type { HistoryItem } from "@/components/FetchHistory"; import type { HistoryItem } from "@/components/FetchHistory";
import { useDownload } from "@/hooks/useDownload"; import { useDownload } from "@/hooks/useDownload";
import { useMetadata } from "@/hooks/useMetadata"; import { useMetadata } from "@/hooks/useMetadata";
import { useLyrics } from "@/hooks/useLyrics"; import { useLyrics } from "@/hooks/useLyrics";
import { useCover } from "@/hooks/useCover"; import { useCover } from "@/hooks/useCover";
import { useAvailability } from "@/hooks/useAvailability"; import { useAvailability } from "@/hooks/useAvailability";
import { ensureSpotiFLACNextStatusCheckStarted } from "@/lib/api-status"; import { ensureApiStatusCheckStarted } from "@/lib/api-status";
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog"; import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
import { useDownloadProgress } from "@/hooks/useDownloadProgress"; import { useDownloadProgress } from "@/hooks/useDownloadProgress";
import { buildPlaylistFolderName } from "@/lib/playlist"; import { buildPlaylistFolderName } from "@/lib/playlist";
@@ -198,7 +199,7 @@ function App() {
}; };
mediaQuery.addEventListener("change", handleChange); mediaQuery.addEventListener("change", handleChange);
checkForUpdates(); checkForUpdates();
ensureSpotiFLACNextStatusCheckStarted(); ensureApiStatusCheckStarted();
void loadHistory(); void loadHistory();
return () => { return () => {
mediaQuery.removeEventListener("change", handleChange); mediaQuery.removeEventListener("change", handleChange);
@@ -528,8 +529,10 @@ function App() {
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn}/>; return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn}/>;
case "debug": case "debug":
return <DebugLoggerPage />; return <DebugLoggerPage />;
case "about": case "projects":
return <AboutPage />; return <OtherProjects />;
case "support":
return <SupportPage />;
case "history": case "history":
return <HistoryPage onHistorySelect={(cachedData) => { return <HistoryPage onHistorySelect={(cachedData) => {
metadata.loadFromCache(cachedData); metadata.loadFromCache(cachedData);
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 749.3 227.1" fill="#FFFFFF">
<!-- Generator: Adobe Illustrator 29.3.1, SVG Export Plug-In . SVG Version: 2.1.0 Build 151) -->
<path d="M222.8,85.4c0-3.4,2.5-5.6,6.4-5.6h18.6c16.9,0,28.3,9.3,28.3,22.9s-11.3,23.3-28.3,23.3h-2.6c-6.5,0-9.8,3.4-9.8,8.8v15.2c0,4.3-2.5,7-6.3,7s-6.3-2.7-6.3-7v-64.6ZM235.4,104.7c0,6.8,3.5,10.1,10.1,10.1h1.6c9.3,0,16.1-3.8,16.1-12.1s-6.8-12.1-16.1-12.1h-1.6c-6.6,0-10.1,3.2-10.1,10.1v4.1ZM276.1,151.1c0,3.6,2.5,5.9,6.3,5.9s4.8-1.6,6.1-5l2.3-6.1c1.8-4.9,5.1-7.1,8.6-7.1h20.5c3.6,0,6.8,2.3,8.6,7.1l2.3,6.1c1.3,3.4,3.6,5,6.1,5,3.8,0,6.3-2.4,6.3-5.9s-.2-2.2-.6-3.4l-24.5-63.8c-1.5-3.9-5-5.8-8.3-5.8s-6.8,1.9-8.3,5.8l-24.5,63.8c-.4,1.2-.6,2.4-.6,3.4ZM300,122.1c0-1.2.3-2.3.9-3.9l4.6-12.9c.9-2.5,2.4-3.7,4.1-3.7s3.2,1.2,4.1,3.7l4.6,12.9c.5,1.6.9,2.7.9,3.9,0,3.2-1.8,5.5-6.7,5.5h-5.8c-4.9,0-6.7-2.3-6.7-5.5ZM339,85.6c0-3.5,2.5-5.8,6.5-5.8h49.7c4,0,6.5,2.4,6.5,5.8s-2.5,5.8-6.5,5.8h-8.3c-6.6,0-10.2,3.4-10.2,11v47.4c0,4.4-2.5,7.1-6.4,7.1s-6.4-2.7-6.4-7.1v-47.4c0-7.7-3.6-11-10.2-11h-8.3c-4,0-6.5-2.4-6.5-5.8ZM413.4,150c0,4.3,2.5,7,6.3,7s6.3-2.7,6.3-7v-17.2c0-4.9,2.8-6.9,6.3-6.9h.9c2.3,0,4.5,1.4,5.9,3.5l16.4,24.1c1.5,2.3,3.5,3.6,5.9,3.6s5.8-2.7,5.8-5.9-.4-2.7-1.4-4.1l-10.9-15.3c-1.3-1.8-1.8-3.4-1.8-4.6,0-2.7,2.4-4.6,5.2-6.7,5.1-3.8,10.6-8.8,10.6-18.3s-10.4-22.3-27.5-22.3h-21.7c-3.9,0-6.3,2.3-6.3,5.6v64.6ZM425.9,103.7v-3.2c0-7,3.7-9.9,9.3-9.9h5.4c9.3,0,15.2,3.5,15.2,11.5s-6.3,11.7-15.6,11.7h-5.1c-5.6,0-9.3-2.9-9.3-9.9ZM484.8,149.8v-64.4c0-3.4,2.4-5.6,6.3-5.6h40.9c3.9,0,6.3,2.3,6.3,5.6s-2.4,5.6-6.3,5.6h-25.8c-5.1,0-8.8,3-8.8,8.8v2.4c0,5.7,3.7,8.8,8.8,8.8h20c3.9,0,6.3,2.3,6.3,5.6s-2.4,5.6-6.3,5.6h-19.2c-5.1,0-9.5,3.1-9.5,9.5v3c0,6.4,4.4,9.5,9.5,9.5h25.1c3.9,0,6.3,2.3,6.3,5.6s-2.4,5.6-6.3,5.6h-40.9c-3.9,0-6.3-2.3-6.3-5.6ZM545.7,117.6c0-23.3,17.5-39.4,38-39.4s38,16.1,38,39.4-17.5,39.4-38,39.4-38-16.1-38-39.4ZM559.9,117.6c0,16.4,9.7,26.9,23.8,26.9s23.8-10.5,23.8-26.9-9.7-26.9-23.8-26.9-23.8,10.4-23.8,26.9ZM636.8,150c0,4.3,2.5,7,6.3,7s6.3-2.7,6.3-7v-33.1c0-4,2.4-5.9,4.9-5.9s3.6,1.1,4.8,3l20.8,34.7c2.8,4.8,5.4,8.3,10.7,8.3s8.8-3.7,8.8-9.6v-62.2c0-4.3-2.5-7-6.3-7s-6.3,2.7-6.3,7v33.1c0,4-2.4,5.9-4.9,5.9s-3.6-1.1-4.8-3l-20.8-34.7c-2.8-4.8-5.4-8.3-10.7-8.3s-8.8,3.7-8.8,9.6v62.2Z"/>
<path d="M169.2,87.5c0-16.7-13-30.3-28.2-35.2-18.9-6.1-43.8-5.2-61.9,3.3-21.9,10.3-28.7,32.9-29,55.5-.2,18.5,1.6,67.4,29.2,67.7,20.5.3,23.5-26.1,33-38.8,6.7-9,15.4-11.6,26.1-14.2,18.4-4.5,30.9-19,30.8-38.2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 27.4.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 1080 1080" style="enable-background:new 0 0 1080 1080;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<path class="st0" d="M1033.05,324.45c-0.19-137.9-107.59-250.92-233.6-291.7c-156.48-50.64-362.86-43.3-512.28,27.2
C106.07,145.41,49.18,332.61,47.06,519.31c-1.74,153.5,13.58,557.79,241.62,560.67c169.44,2.15,194.67-216.18,273.07-321.33
c55.78-74.81,127.6-95.94,216.01-117.82C929.71,603.22,1033.27,483.3,1033.05,324.45z"/>
</svg>

After

Width:  |  Height:  |  Size: 735 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

+23 -17
View File
@@ -1,14 +1,14 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { SearchCheck, CheckCircle2, XCircle, Loader2 } from "lucide-react"; import { PlugZap, CheckCircle2, Loader2, Wrench } from "lucide-react";
import { TidalIcon, QobuzIcon, AmazonIcon, MusicBrainzIcon, AppleMusicIcon, DeezerIcon } from "./PlatformIcons"; import { TidalIcon, QobuzIcon, AmazonIcon, AppleMusicIcon, DeezerIcon } from "./PlatformIcons";
import { useApiStatus } from "@/hooks/useApiStatus"; import { useApiStatus } from "@/hooks/useApiStatus";
import { SPOTIFLAC_NEXT_SOURCES } from "@/lib/api-status"; 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") { if (status === "online") {
return <CheckCircle2 className="h-5 w-5 text-emerald-500"/>; return <CheckCircle2 className="h-5 w-5 text-emerald-500"/>;
} }
if (status === "offline") { if (status === "offline") {
return <XCircle className="h-5 w-5 text-destructive"/>; return <Wrench className="h-4 w-4 text-amber-600 dark:text-amber-400"/>;
} }
return null; return null;
} }
@@ -19,9 +19,6 @@ function renderPlatformIcon(type: string) {
if (type === "amazon") { if (type === "amazon") {
return <AmazonIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>; return <AmazonIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
} }
if (type === "musicbrainz") {
return <MusicBrainzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
}
if (type === "deezer") { if (type === "deezer") {
return <DeezerIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>; return <DeezerIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
} }
@@ -31,27 +28,30 @@ function renderPlatformIcon(type: string) {
return <QobuzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>; return <QobuzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
} }
export function ApiStatusTab() { export function ApiStatusTab() {
const { sources, statuses, nextStatuses, checkingSources, checkOne } = useApiStatus(); const { sources, statuses, nextStatuses, checkingSources, checkAllCurrent, checkAllNext } = useApiStatus();
const isCheckingCurrent = sources.some((source) => checkingSources[source.id] === true);
const isCheckingNext = SPOTIFLAC_NEXT_SOURCES.some((source) => nextStatuses[source.id] === "checking");
return (<div className="space-y-6"> return (<div className="space-y-6">
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Services</h3> <div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC</h3>
<Button variant="outline" size="sm" onClick={() => void checkAllCurrent()} disabled={isCheckingCurrent} className="gap-2">
{isCheckingCurrent ? <Loader2 className="h-4 w-4 animate-spin"/> : <PlugZap className="h-4 w-4"/>}
Check
</Button>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{sources.map((source) => { {sources.map((source) => {
const status = statuses[source.id] || "idle"; const status = statuses[source.id] || "idle";
const isChecking = checkingSources[source.id] === true;
return (<div key={source.id} className="space-y-4 p-4 border rounded-lg bg-card text-card-foreground shadow-sm"> return (<div key={source.id} className="space-y-4 p-4 border rounded-lg bg-card text-card-foreground shadow-sm">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{renderPlatformIcon(source.type)} {renderPlatformIcon(source.type)}
<p className="font-medium leading-none">{source.name}</p> <p className="font-medium leading-none">{source.name}</p>
</div> </div>
<div className="flex items-center">{renderStatusIcon(status)}</div> <div className="flex items-center">{renderStatusIndicator(status)}</div>
</div> </div>
<Button variant="outline" size="sm" onClick={() => void checkOne(source.id)} disabled={isChecking} className="w-full gap-2">
{isChecking ? <Loader2 className="h-4 w-4 animate-spin"/> : <SearchCheck className="h-4 w-4"/>}
Check
</Button>
</div>); </div>);
})} })}
</div> </div>
@@ -60,7 +60,13 @@ export function ApiStatusTab() {
<div className="border-t"/> <div className="border-t"/>
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Next Services</h3> <div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Next</h3>
<Button variant="outline" size="sm" onClick={() => void checkAllNext()} disabled={isCheckingNext} className="gap-2">
{isCheckingNext ? <Loader2 className="h-4 w-4 animate-spin"/> : <PlugZap className="h-4 w-4"/>}
Check
</Button>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5">
{SPOTIFLAC_NEXT_SOURCES.map((source) => { {SPOTIFLAC_NEXT_SOURCES.map((source) => {
@@ -70,7 +76,7 @@ export function ApiStatusTab() {
{renderPlatformIcon(source.id)} {renderPlatformIcon(source.id)}
<p className="font-medium leading-none">{source.name}</p> <p className="font-medium leading-none">{source.name}</p>
</div> </div>
<div className="flex items-center">{renderStatusIcon(status)}</div> <div className="flex items-center">{renderStatusIndicator(status)}</div>
</div>); </div>);
})} })}
</div> </div>
@@ -1,24 +1,18 @@
import { useState, useEffect } from "react"; import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { openExternal } from "@/lib/utils"; import { openExternal } from "@/lib/utils";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
import { Star, GitFork, Clock, Download, Blocks, Heart, Copy, CircleCheck, Info } from "lucide-react"; import { Star, GitFork, Clock, Download, Info } from "lucide-react";
import AudioTTSProIcon from "@/assets/audiotts-pro.webp"; import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp"; import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
import XIcon from "@/assets/x.webp"; import XIcon from "@/assets/x.webp";
import XProIcon from "@/assets/x-pro.webp";
import SpotubeDLIcon from "@/assets/icons/spotubedl.svg"; import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg"; import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
import SpotiFLACNextIcon from "@/assets/icons/next.svg"; import SpotiFLACNextIcon from "@/assets/icons/next.svg";
import 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"; import { langColors } from "@/assets/github-lang-colors";
const browserExtensionItems = [ const browserExtensionItems = [
{ icon: AudioTTSProIcon, label: "AudioTTS Pro", alt: "AudioTTS Pro" }, { icon: AudioTTSProIcon, label: "AudioTTS Pro", alt: "AudioTTS Pro" },
{ icon: ChatGPTTTSIcon, label: "ChatGPT TTS", alt: "ChatGPT TTS" }, { icon: ChatGPTTTSIcon, label: "ChatGPT TTS", alt: "ChatGPT TTS" },
{ icon: XIcon, label: "Twitter/X Media Batch Downloader", alt: "Twitter/X Media Batch Downloader" }, { 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 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"; const projectCardHeaderClass = "px-5 gap-1.5";
@@ -26,10 +20,8 @@ const projectCardContentClass = "px-5";
const projectBodyClass = "text-[13px] leading-snug"; const projectBodyClass = "text-[13px] leading-snug";
const releaseMetaClass = "text-xs text-muted-foreground whitespace-nowrap"; 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"; 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() { export function OtherProjects() {
const [activeTab, setActiveTab] = useState<"projects" | "support">("projects");
const [repoStats, setRepoStats] = useState<Record<string, any>>({}); const [repoStats, setRepoStats] = useState<Record<string, any>>({});
const [copiedUsdt, setCopiedUsdt] = useState(false);
useEffect(() => { useEffect(() => {
const fetchRepoStats = async () => { const fetchRepoStats = async () => {
const CACHE_KEY = "github_repo_stats_v4"; const CACHE_KEY = "github_repo_stats_v4";
@@ -181,24 +173,10 @@ export function AboutPage() {
}; };
return (<div className="flex flex-col space-y-3"> return (<div className="flex flex-col space-y-3">
<div className="flex items-center justify-between shrink-0"> <div className="flex items-center justify-between shrink-0">
<h2 className="text-2xl font-bold tracking-tight">About</h2> <h2 className="text-2xl font-bold tracking-tight">Other Projects</h2>
</div> </div>
<div className="flex gap-2 border-b shrink-0"> <div className="flex-1 min-h-0 pr-1.5">
<Button variant={activeTab === "projects" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("projects")} className="rounded-b-none">
<Blocks className="h-4 w-4"/>
Other Projects
</Button>
<Button variant={activeTab === "support" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("support")} className="rounded-b-none">
<Heart className="h-4 w-4"/>
Support Me
</Button>
</div>
<div className="flex-1 min-h-0">
{activeTab === "projects" && (<div className="pr-1.5">
<div className="grid gap-2 grid-cols-3"> <div className="grid gap-2 grid-cols-3">
<Card className={projectCardClass} onClick={() => openExternal("https://github.com/spotbye/SpotiFLAC-Next")}> <Card className={projectCardClass} onClick={() => openExternal("https://github.com/spotbye/SpotiFLAC-Next")}>
<CardHeader className={projectCardHeaderClass}> <CardHeader className={projectCardHeaderClass}>
@@ -249,7 +227,7 @@ export function AboutPage() {
Note Note
</div> </div>
<p className="text-xs leading-snug text-sky-700 dark:text-sky-300"> <p className="text-xs leading-snug text-sky-700 dark:text-sky-300">
This project released as a token of appreciation for those who have supported SpotiFLAC on Ko-fi. Its not a paid product, but its 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. Its not a paid product, but its shared privately through a supporter-only post.
</p> </p>
</div> </div>
</CardContent>)} </CardContent>)}
@@ -313,7 +291,7 @@ export function AboutPage() {
</CardContent>)} </CardContent>)}
</Card> </Card>
<div className="flex h-full flex-col gap-1.5"> <div className="flex h-full flex-col gap-1.5">
<Card className={`${projectCardClass} flex-1`} onClick={() => openExternal("https://exyezed.qzz.io/")}> <Card className={`${projectCardClass} flex-1`} onClick={() => openExternal("https://exyezed.fyi/")}>
<CardHeader className={projectCardHeaderClass}> <CardHeader className={projectCardHeaderClass}>
<CardTitle className="leading-tight">Browser Extensions & Scripts</CardTitle> <CardTitle className="leading-tight">Browser Extensions & Scripts</CardTitle>
<CardDescription className="flex flex-col gap-2.5 pt-1.5"> <CardDescription className="flex flex-col gap-2.5 pt-1.5">
@@ -339,55 +317,6 @@ export function AboutPage() {
</Card> </Card>
</div> </div>
</div> </div>
</div>)}
{activeTab === "support" && (<div className="flex flex-col items-center justify-center p-4 space-y-6">
<div className="flex flex-col md:flex-row w-full max-w-3xl bg-card rounded-xl border shadow-sm">
<div className="flex-1 p-6 flex flex-col items-center justify-between border-b md:border-b-0 md:border-r space-y-6">
<div className="flex flex-col items-center space-y-4">
<div className="h-32 flex items-center justify-center w-full relative">
<img src={KofiLogo} className="w-72 absolute pointer-events-none" alt="Ko-fi"/>
</div>
<h4 className="font-semibold text-foreground">Support via Ko-fi</h4>
<p className="text-sm text-muted-foreground text-center px-4">
Enjoying the project? You can support ongoing development by buying me a coffee.
</p>
</div>
<Button className="h-10 w-full text-sm font-semibold text-white gap-2 group bg-[#72a4f2] hover:bg-[#5f8cd6]" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
<img src={KofiSvg} className="w-5 h-5 shrink-0" alt="" aria-hidden="true"/>
Support me on Ko-fi
</Button>
</div>
<div className="flex-1 p-6 flex flex-col items-center justify-between space-y-6">
<div className="flex flex-col items-center space-y-4 w-full">
<div className="h-32 flex items-center justify-center">
<div className="p-2 bg-white rounded-xl shadow-sm border">
<img src={UsdtBarcode} className="w-24 h-24 object-contain" alt="USDT Barcode"/>
</div>
</div>
<h4 className="font-semibold text-foreground">USDT (TRC20)</h4>
<p className="text-sm text-muted-foreground text-center px-4">
Crypto donations are also accepted. Scan the QR code or copy the address.
</p>
</div>
<div className="flex items-center gap-2 bg-muted/50 pl-3 pr-1.5 py-1.5 rounded-lg border w-full justify-between h-10">
<code className="text-xs font-mono text-muted-foreground truncate" title="THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs">
THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs
</code>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 hover:bg-background" onClick={() => {
navigator.clipboard.writeText("THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs");
setCopiedUsdt(true);
setTimeout(() => setCopiedUsdt(false), 500);
}}>
{copiedUsdt ? <CircleCheck className="h-3.5 w-3.5 text-green-500"/> : <Copy className="h-3.5 w-3.5"/>}
</Button>
</div>
</div>
</div>
</div>)}
</div> </div>
</div>); </div>);
} }
+207 -188
View File
@@ -6,10 +6,10 @@ import { InputWithContext } from "@/components/ui/input-with-context";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { FolderOpen, Save, RotateCcw, Info, ArrowRight, 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 { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, 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 { themes, applyTheme } from "@/lib/themes";
import { SelectFolder, OpenConfigFolder, CheckCustomTidalAPI } from "../../wailsjs/go/main/App"; import { SelectFolder, OpenConfigFolder, CheckCustomTidalAPI } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
@@ -33,6 +33,11 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
const parsedAddFont = parseGoogleFontUrl(addFontUrl); const parsedAddFont = parseGoogleFontUrl(addFontUrl);
const fontOptions = getFontOptions(tempSettings.customFonts); const fontOptions = getFontOptions(tempSettings.customFonts);
const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings); 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 resetToSaved = useCallback(() => {
const freshSavedSettings = getSettings(); const freshSavedSettings = getSettings();
flushSync(() => { flushSync(() => {
@@ -96,7 +101,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
}, []); }, []);
const handleSave = async () => { const handleSave = async () => {
await saveSettings(tempSettings); await saveSettings(tempSettings);
setSavedSettings(tempSettings); const persistedSettings = getSettings();
setSavedSettings(persistedSettings);
setTempSettings(persistedSettings);
toast.success("Settings saved"); toast.success("Settings saved");
onUnsavedChangesChange?.(false); onUnsavedChangesChange?.(false);
}; };
@@ -184,13 +191,15 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
customTidalApi: normalizedValue, customTidalApi: normalizedValue,
}; };
await saveSettings(nextSavedSettings); await saveSettings(nextSavedSettings);
setSavedSettings((prev) => ({ const nextSavedState = getSettings();
...prev, setSavedSettings(nextSavedState);
customTidalApi: normalizedValue,
}));
setTempSettings((prev) => ({ setTempSettings((prev) => ({
...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 () => { const handleCheckCustomTidalApi = async () => {
@@ -216,7 +225,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
toast.error(`Failed to check HiFi API instance: ${error}`); toast.error(`Failed to check HiFi API instance: ${error}`);
} }
}; };
const [activeTab, setActiveTab] = useState<"general" | "files" | "api">("general"); const [activeTab, setActiveTab] = useState<"general" | "download" | "files" | "metadata" | "status">("general");
return (<div className="space-y-4 h-full flex flex-col"> return (<div className="space-y-4 h-full flex flex-col">
<div className="flex items-center justify-between shrink-0"> <div className="flex items-center justify-between shrink-0">
<h1 className="text-2xl font-bold">Settings</h1> <h1 className="text-2xl font-bold">Settings</h1>
@@ -248,33 +257,27 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
<MonitorCog className="h-4 w-4"/> <MonitorCog className="h-4 w-4"/>
General General
</Button> </Button>
<Button variant={activeTab === "download" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("download")} className="rounded-b-none gap-2">
<Download className="h-4 w-4"/>
Download
</Button>
<Button variant={activeTab === "files" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("files")} className="rounded-b-none gap-2"> <Button variant={activeTab === "files" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("files")} className="rounded-b-none gap-2">
<FolderCog className="h-4 w-4"/> <FolderCog className="h-4 w-4"/>
File Management Files
</Button> </Button>
<Button variant={activeTab === "api" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("api")} className="rounded-b-none gap-2"> <Button variant={activeTab === "metadata" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("metadata")} className="rounded-b-none gap-2">
<Tags className="h-4 w-4"/>
Metadata
</Button>
<Button variant={activeTab === "status" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("status")} className="rounded-b-none gap-2">
<Router className="h-4 w-4"/> <Router className="h-4 w-4"/>
Status Status
</Button> </Button>
</div> </div>
<div className="flex-1 overflow-y-auto pt-4"> <div className="flex-1 overflow-y-auto pt-4">
{activeTab === "general" && (<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> {activeTab === "general" && (<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="download-path">Download Path</Label>
<div className="flex gap-2">
<InputWithContext id="download-path" value={tempSettings.downloadPath} onChange={(e) => setTempSettings((prev) => ({
...prev,
downloadPath: e.target.value,
}))} placeholder="C:\Users\YourUsername\Music"/>
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
<FolderOpen className="h-4 w-4"/>
Browse
</Button>
</div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="theme-mode">Mode</Label> <Label htmlFor="theme-mode">Mode</Label>
<Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}> <Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}>
@@ -309,7 +312,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div>
<div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="font">Font</Label> <Label htmlFor="font">Font</Label>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -357,50 +362,27 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</Label> </Label>
</div> </div>
</div> </div>
</div>)}
{activeTab === "download" && (<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="link-resolver">Link Resolver</Label> <Label>Tidal Source</Label>
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<Select value={tempSettings.linkResolver} onValueChange={(value: "songstats" | "songlink") => setTempSettings((prev) => ({ <Button type="button" variant="outline" onClick={() => setShowCustomTidalApiDialog(true)} className="gap-2">
...prev, <TidalIcon />
linkResolver: value, Add Instance
}))}> </Button>
<SelectTrigger id="link-resolver" className="h-9 w-fit min-w-35"> {tempSettings.customTidalApi && (<span className="max-w-[260px] truncate text-xs text-muted-foreground" title={tempSettings.customTidalApi}>
<SelectValue placeholder="Select a link resolver"/> {tempSettings.customTidalApi}
</SelectTrigger> </span>)}
<SelectContent>
<SelectItem value="songlink">
<span className="flex items-center gap-2">
<SonglinkIcon className="h-4 w-4 shrink-0"/>
Songlink
</span>
</SelectItem>
<SelectItem value="songstats">
<span className="flex items-center gap-2">
<SongstatsIcon className="h-4 w-4 shrink-0"/>
Songstats
</span>
</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center gap-3">
<Switch id="allow-link-resolver-fallback" checked={tempSettings.allowResolverFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
allowResolverFallback: checked,
}))}/>
<Label htmlFor="allow-link-resolver-fallback" className="text-sm font-normal cursor-pointer">
Allow Fallback
</Label>
</div>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="downloader">Source</Label> <Label htmlFor="downloader">Source</Label>
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<Select value={tempSettings.downloader} onValueChange={(value: SettingsType["downloader"]) => setTempSettings((prev) => ({ <Select value={effectiveDownloader} onValueChange={(value: SettingsType["downloader"]) => setTempSettings((prev) => ({
...prev, ...prev,
downloader: value, downloader: value,
}))}> }))}>
@@ -409,12 +391,12 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="auto">Auto</SelectItem> <SelectItem value="auto">Auto</SelectItem>
<SelectItem value="tidal"> {hasCustomTidalInstanceConfigured && (<SelectItem value="tidal">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<TidalIcon /> <TidalIcon />
Tidal Tidal
</span> </span>
</SelectItem> </SelectItem>)}
<SelectItem value="qobuz"> <SelectItem value="qobuz">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<QobuzIcon /> <QobuzIcon />
@@ -427,20 +409,19 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
Amazon Music Amazon Music
</span> </span>
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{tempSettings.downloader === "auto" && (<> {effectiveDownloader === "auto" && (<>
<Select value={tempSettings.autoOrder || "tidal-qobuz-amazon"} onValueChange={(value: string) => setTempSettings((prev) => ({ <Select value={effectiveAutoOrder} onValueChange={(value: string) => setTempSettings((prev) => ({
...prev, ...prev,
autoOrder: value, autoOrder: value,
}))}> }))}>
<SelectTrigger className="h-9 w-fit min-w-35"> <SelectTrigger className="h-9 w-auto">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="w-fit min-w-max">
{hasCustomTidalInstanceConfigured && (<>
<SelectItem value="tidal-qobuz-amazon"> <SelectItem value="tidal-qobuz-amazon">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/> <TidalIcon className="fill-current"/>
@@ -495,8 +476,6 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
<TidalIcon className="fill-current"/> <TidalIcon className="fill-current"/>
</span> </span>
</SelectItem> </SelectItem>
<SelectItem value="tidal-qobuz"> <SelectItem value="tidal-qobuz">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/> <TidalIcon className="fill-current"/>
@@ -518,13 +497,6 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
<TidalIcon className="fill-current"/> <TidalIcon className="fill-current"/>
</span> </span>
</SelectItem> </SelectItem>
<SelectItem value="qobuz-amazon">
<span className="flex items-center gap-1.5">
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="amazon-tidal"> <SelectItem value="amazon-tidal">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<AmazonIcon className="fill-current"/> <AmazonIcon className="fill-current"/>
@@ -532,6 +504,14 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
<TidalIcon className="fill-current"/> <TidalIcon className="fill-current"/>
</span> </span>
</SelectItem> </SelectItem>
</>)}
<SelectItem value="qobuz-amazon">
<span className="flex items-center gap-1.5">
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="amazon-qobuz"> <SelectItem value="amazon-qobuz">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<AmazonIcon className="fill-current"/> <AmazonIcon className="fill-current"/>
@@ -553,19 +533,17 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</Select> </Select>
</>)} </>)}
{tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}> {effectiveDownloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
<SelectTrigger className="h-9 w-fit"> <SelectTrigger className="h-9 w-fit">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="LOSSLESS">16-bit/44.1kHz</SelectItem> <SelectItem value="LOSSLESS">16-bit/44.1kHz</SelectItem>
<SelectItem value="HI_RES_LOSSLESS"> <SelectItem value="HI_RES_LOSSLESS">24-bit/48kHz</SelectItem>
24-bit/48kHz
</SelectItem>
</SelectContent> </SelectContent>
</Select>)} </Select>)}
{tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={handleQobuzQualityChange}> {effectiveDownloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={handleQobuzQualityChange}>
<SelectTrigger className="h-9 w-fit"> <SelectTrigger className="h-9 w-fit">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -575,17 +553,16 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</SelectContent> </SelectContent>
</Select>)} </Select>)}
{tempSettings.downloader === "amazon" && (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default"> {effectiveDownloader === "amazon" && (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default">
16-bit - 24-bit/44.1kHz - 192kHz 16-bit - 24-bit/44.1kHz - 192kHz
</div>)} </div>)}
</div> </div>
{((tempSettings.downloader === "tidal" && {((effectiveDownloader === "tidal" &&
tempSettings.tidalQuality === "HI_RES_LOSSLESS") || tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
(tempSettings.downloader === "qobuz" && (effectiveDownloader === "qobuz" &&
tempSettings.qobuzQuality === "27") || tempSettings.qobuzQuality === "27") ||
(tempSettings.downloader === "auto" && (effectiveDownloader === "auto" &&
tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pt-2"> tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pt-2">
<Switch id="allow-fallback" checked={tempSettings.allowFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({ <Switch id="allow-fallback" checked={tempSettings.allowFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev, ...prev,
@@ -595,66 +572,66 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
Allow Quality Fallback (16-bit) Allow Quality Fallback (16-bit)
</Label> </Label>
</div>)} </div>)}
</div>
</div>
{(tempSettings.downloader === "auto" || tempSettings.downloader === "tidal") && (<div className="space-y-2 pt-2"> <div className="space-y-4">
<Label>Custom Instance</Label> <div className="space-y-2">
<div className="flex items-center gap-2"> <Label htmlFor="link-resolver">Link Resolver</Label>
<Button type="button" variant="outline" onClick={() => setShowCustomTidalApiDialog(true)} className="gap-2"> <div className="flex items-center gap-3 flex-wrap">
<TidalIcon /> <Select value={tempSettings.linkResolver} onValueChange={(value: "songstats" | "songlink") => setTempSettings((prev) => ({
Configure ...prev,
linkResolver: value,
}))}>
<SelectTrigger id="link-resolver" className="h-9 w-fit min-w-35">
<SelectValue placeholder="Select a link resolver"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="songlink">
<span className="flex items-center gap-2">
<SonglinkIcon className="h-4 w-4 shrink-0"/>
Songlink
</span>
</SelectItem>
<SelectItem value="songstats">
<span className="flex items-center gap-2">
<SongstatsIcon className="h-4 w-4 shrink-0"/>
Songstats
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-3 pt-2">
<Switch id="allow-link-resolver-fallback" checked={tempSettings.allowResolverFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
allowResolverFallback: checked,
}))}/>
<Label htmlFor="allow-link-resolver-fallback" className="text-sm font-normal cursor-pointer">
Allow Resolver Fallback
</Label>
</div>
</div>
</div>)}
{activeTab === "files" && (<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-8 items-start">
<div className="space-y-4 lg:pr-8 lg:border-r">
<div className="space-y-2">
<Label htmlFor="download-path">Download Path</Label>
<div className="flex gap-2">
<InputWithContext id="download-path" value={tempSettings.downloadPath} onChange={(e) => setTempSettings((prev) => ({
...prev,
downloadPath: e.target.value,
}))} placeholder="C:\Users\YourUsername\Music"/>
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
<FolderOpen className="h-4 w-4"/>
Browse
</Button> </Button>
{tempSettings.customTidalApi && (<span className="max-w-[260px] truncate text-xs text-muted-foreground" title={tempSettings.customTidalApi}>
{tempSettings.customTidalApi}
</span>)}
</div> </div>
</div>)}
</div> </div>
<div className="border-t pt-2"/>
<div className="space-y-4">
<div className="flex items-center gap-3">
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
embedMaxQualityCover: checked,
}))}/>
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm font-normal">
Embed Max Quality Cover
</Label>
</div>
<div className="flex items-center gap-3">
<Switch id="embed-genre" checked={tempSettings.embedGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
embedGenre: checked,
}))}/>
<Label htmlFor="embed-genre" className="cursor-pointer text-sm font-normal">
Embed Genre
</Label>
</div>
{tempSettings.embedGenre && (<div className="flex items-center gap-3">
<Switch id="use-single-genre" checked={tempSettings.useSingleGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
useSingleGenre: checked,
}))}/>
<Label htmlFor="use-single-genre" className="text-sm cursor-pointer font-normal">
Use Single Genre
</Label>
</div>)}
<div className="flex items-center gap-3">
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
embedLyrics: checked,
}))}/>
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm font-normal">
Embed Lyrics
</Label>
</div>
</div>
</div>
</div>)}
{activeTab === "files" && (<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="text-sm">Folder Structure</Label> <Label className="text-sm">Folder Structure</Label>
@@ -742,31 +719,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
Create M3U8 Playlist File Create M3U8 Playlist File
</Label> </Label>
</div> </div>
<div className="flex items-center gap-3">
<Switch id="use-first-artist-only" checked={tempSettings.useFirstArtistOnly} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
useFirstArtistOnly: checked,
}))}/>
<Label htmlFor="use-first-artist-only" className="text-sm cursor-pointer font-normal">
Use First Artist Only
</Label>
</div> </div>
<div className="flex items-center gap-3"> <div className="space-y-4 lg:pl-0">
<Switch id="redownload-with-suffix" checked={tempSettings.redownloadWithSuffix} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
redownloadWithSuffix: checked,
}))}/>
<Label htmlFor="redownload-with-suffix" className="text-sm cursor-pointer font-normal">
Redownload With Suffix
</Label>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="existing-file-check-mode">Existing File Check</Label> <Label htmlFor="existing-file-check-mode">Existing File Check</Label>
<Select value={tempSettings.existingFileCheckMode} onValueChange={(value: ExistingFileCheckMode) => setTempSettings((prev) => ({ <Select value={tempSettings.existingFileCheckMode} onValueChange={(value: ExistingFileCheckMode) => setTempSettings((prev) => ({
@@ -823,24 +778,6 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
filenameTemplate: e.target.value, filenameTemplate: e.target.value,
}))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)} }))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)}
</div> </div>
<div className="space-y-2 pt-2">
<Label className="text-sm">Separator</Label>
<div className="flex gap-2">
<Select value={tempSettings.separator} onValueChange={(value: "comma" | "semicolon") => setTempSettings((prev) => ({
...prev,
separator: value,
}))}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="comma">Comma (,)</SelectItem>
<SelectItem value="semicolon">Semicolon (;)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground"> {tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
Preview:{" "} Preview:{" "}
<span className="font-mono"> <span className="font-mono">
@@ -858,10 +795,92 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</span> </span>
</p>)} </p>)}
</div> </div>
<div className="space-y-2">
<Label className="text-sm">Separator</Label>
<Select value={tempSettings.separator} onValueChange={(value: "comma" | "semicolon") => setTempSettings((prev) => ({
...prev,
separator: value,
}))}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="comma">Comma (,)</SelectItem>
<SelectItem value="semicolon">Semicolon (;)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<Switch id="redownload-with-suffix" checked={tempSettings.redownloadWithSuffix} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
redownloadWithSuffix: checked,
}))}/>
<Label htmlFor="redownload-with-suffix" className="text-sm cursor-pointer font-normal">
Redownload With Suffix
</Label>
</div>
</div> </div>
</div>)} </div>)}
{activeTab === "api" && (<ApiStatusTab />)} {activeTab === "metadata" && (<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div className="space-y-4">
<div className="flex items-center gap-3">
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
embedLyrics: checked,
}))}/>
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm font-normal">
Embed Lyrics
</Label>
</div>
<div className="flex items-center gap-3">
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
embedMaxQualityCover: checked,
}))}/>
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm font-normal">
Embed Max Quality Cover
</Label>
</div>
<div className="flex items-center gap-3">
<Switch id="embed-genre" checked={tempSettings.embedGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
embedGenre: checked,
}))}/>
<Label htmlFor="embed-genre" className="cursor-pointer text-sm font-normal">
Embed Genre
</Label>
</div>
{tempSettings.embedGenre && (<div className="flex items-center gap-3">
<Switch id="use-single-genre" checked={tempSettings.useSingleGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
useSingleGenre: checked,
}))}/>
<Label htmlFor="use-single-genre" className="text-sm cursor-pointer font-normal">
Use Single Genre
</Label>
</div>)}
</div>
<div className="space-y-4">
<div className="flex items-center gap-3">
<Switch id="use-first-artist-only" checked={tempSettings.useFirstArtistOnly} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
useFirstArtistOnly: checked,
}))}/>
<Label htmlFor="use-first-artist-only" className="text-sm cursor-pointer font-normal">
Use First Artist Only
</Label>
</div>
</div>
</div>)}
{activeTab === "status" && (<ApiStatusTab />)}
</div> </div>
<Dialog open={showAddFontDialog} onOpenChange={(open) => open ? setShowAddFontDialog(true) : closeAddFontDialog()}> <Dialog open={showAddFontDialog} onOpenChange={(open) => open ? setShowAddFontDialog(true) : closeAddFontDialog()}>
@@ -915,7 +934,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
<DialogContent className="sm:max-w-md [&>button]:hidden"> <DialogContent className="sm:max-w-md [&>button]:hidden">
<DialogHeader> <DialogHeader>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<DialogTitle>Custom Instance</DialogTitle> <DialogTitle>Tidal Source</DialogTitle>
<button type="button" onClick={() => openExternal("https://github.com/binimum/hifi-api")} className="inline-flex cursor-pointer items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline"> <button type="button" onClick={() => openExternal("https://github.com/binimum/hifi-api")} className="inline-flex cursor-pointer items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline">
How to create your own instance How to create your own instance
<ExternalLink className="h-3 w-3"/> <ExternalLink className="h-3 w-3"/>
@@ -932,8 +951,8 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
setCustomTidalApiStatus("idle"); setCustomTidalApiStatus("idle");
void persistCustomTidalApi(nextValue); void persistCustomTidalApi(nextValue);
}} placeholder="https://your-hifi-api.example"/> }} placeholder="https://your-hifi-api.example"/>
<Button type="button" variant="outline" onClick={() => void handleCheckCustomTidalApi()} disabled={!((tempSettings.customTidalApi || "").trim().startsWith("https://")) || customTidalApiStatus === "checking"}> <Button type="button" variant="outline" className="gap-2" onClick={() => void handleCheckCustomTidalApi()} disabled={!((tempSettings.customTidalApi || "").trim().startsWith("https://")) || customTidalApiStatus === "checking"}>
{customTidalApiStatus === "checking" ? "Checking..." : "Check"} {customTidalApiStatus === "checking" ? "Checking..." : <><PlugZap className="h-4 w-4"/>Check</>}
</Button> </Button>
{tempSettings.customTidalApi && (<Button type="button" variant="outline" size="icon" onClick={() => { {tempSettings.customTidalApi && (<Button type="button" variant="outline" size="icon" onClick={() => {
setCustomTidalApiStatus("idle"); setCustomTidalApiStatus("idle");
+10 -10
View File
@@ -6,18 +6,18 @@ import { ActivityIcon, type ActivityIconHandle } from "@/components/ui/activity"
import { TerminalIcon } from "@/components/ui/terminal"; import { TerminalIcon } from "@/components/ui/terminal";
import { FileMusicIcon, type FileMusicIconHandle } from "@/components/ui/file-music"; import { FileMusicIcon, type FileMusicIconHandle } from "@/components/ui/file-music";
import { FilePenIcon, type FilePenIconHandle } from "@/components/ui/file-pen"; import { FilePenIcon, type FilePenIconHandle } from "@/components/ui/file-pen";
import { BugReportIcon } from "@/components/ui/bug-report-icon";
import { CoffeeIcon } from "@/components/ui/coffee"; import { CoffeeIcon } from "@/components/ui/coffee";
import { BadgeAlertIcon } from "@/components/ui/badge-alert";
import { GithubIcon } from "@/components/ui/github";
import { BlocksIcon } from "@/components/ui/blocks-icon"; import { BlocksIcon } from "@/components/ui/blocks-icon";
import { AudioLinesIcon, type AudioLinesIconHandle } from "@/components/ui/audio-lines"; import { AudioLinesIcon, type AudioLinesIconHandle } from "@/components/ui/audio-lines";
import { ToolCaseIcon } from "@/components/ui/tool-case";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { openExternal } from "@/lib/utils"; import { openExternal } from "@/lib/utils";
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "about" | "history"; export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "projects" | "support" | "history";
interface SidebarProps { interface SidebarProps {
currentPage: PageType; currentPage: PageType;
onPageChange: (page: PageType) => void; onPageChange: (page: PageType) => void;
@@ -100,7 +100,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant={["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`}> <Button variant={["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`}>
<BlocksIcon size={20} loop={true}/> <ToolCaseIcon size={20}/>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -134,7 +134,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => setIsIssuesDialogOpen(true)}> <Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => setIsIssuesDialogOpen(true)}>
<GithubIcon size={20}/> <BugReportIcon size={20} loop={true}/>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
@@ -176,23 +176,23 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant={currentPage === "about" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "about" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("about")}> <Button variant={currentPage === "projects" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "projects" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("projects")}>
<BadgeAlertIcon size={20}/> <BlocksIcon size={20} loop={true}/>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>About</p> <p>Other Projects</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}> <Button variant={currentPage === "support" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "support" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("support")}>
<CoffeeIcon size={20} loop={true}/> <CoffeeIcon size={20} loop={true}/>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>Support me on Ko-fi</p> <p>Support Me</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
+97
View File
@@ -0,0 +1,97 @@
import { useState } from "react";
import { CircleCheck, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { openExternal } from "@/lib/utils";
import KofiLogo from "@/assets/ko-fi.gif";
import KofiSvg from "@/assets/kofi_symbol.svg";
import PatreonLogo from "@/assets/patreon.svg";
import PatreonSymbol from "@/assets/patreon_symbol.svg";
import UsdtBarcode from "@/assets/usdt.jpg";
export function SupportPage() {
const [copiedUsdt, setCopiedUsdt] = useState(false);
const [copiedEmail, setCopiedEmail] = useState(false);
return (<div className="flex flex-col space-y-3">
<div className="flex items-center justify-between shrink-0">
<h2 className="text-2xl font-bold tracking-tight">Support Me</h2>
</div>
<div className="flex flex-col items-center justify-center p-4">
<div className="grid w-full max-w-5xl overflow-hidden rounded-xl border bg-card shadow-sm md:grid-cols-3">
<div className="flex min-h-[22rem] flex-col items-center justify-between space-y-6 border-b p-6 md:border-b-0 md:border-r">
<div className="flex flex-col items-center space-y-4">
<div className="h-32 flex items-center justify-center w-full relative">
<img src={KofiLogo} className="w-72 absolute pointer-events-none" alt="Ko-fi"/>
</div>
<h4 className="font-semibold text-foreground">Support via Ko-fi</h4>
<p className="text-sm text-muted-foreground text-center px-4">
Buy me a coffee to help keep development going.
</p>
</div>
<Button className="h-10 w-full gap-2 bg-[#72a4f2] text-sm font-semibold text-white hover:bg-[#5f8cd6]" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
<img src={KofiSvg} className="h-6 w-6 shrink-0" alt="" aria-hidden="true"/>
Support me on Ko-fi
</Button>
</div>
<div className="flex min-h-[22rem] flex-col items-center justify-between space-y-6 border-b p-6 md:border-b-0 md:border-r">
<div className="flex flex-col items-center space-y-4 w-full">
<div className="h-32 flex items-center justify-center w-full px-4">
<img src={PatreonLogo} className="w-56 max-w-full brightness-0 dark:brightness-100" alt="Patreon"/>
</div>
<h4 className="font-semibold text-foreground">Support via Patreon</h4>
<p className="text-sm text-muted-foreground text-center px-4">
Join on Patreon to help fund the project and follow updates.
</p>
</div>
<Button className="h-10 w-full gap-2 bg-[#ff424d] text-sm font-semibold text-white hover:bg-[#e63945]" onClick={() => openExternal("https://www.patreon.com/cw/afkarxyz")}>
<img src={PatreonSymbol} className="h-5 w-5 shrink-0" alt="" aria-hidden="true"/>
Support me on Patreon
</Button>
</div>
<div className="flex min-h-[22rem] flex-col items-center justify-between space-y-6 p-6">
<div className="flex flex-col items-center space-y-4 w-full">
<div className="h-32 flex items-center justify-center">
<div className="rounded-xl border bg-white p-2 shadow-sm">
<img src={UsdtBarcode} className="h-24 w-24 object-contain" alt="USDT Barcode"/>
</div>
</div>
<h4 className="font-semibold text-foreground">USDT (TRC20)</h4>
<p className="text-sm text-muted-foreground text-center px-4">
Prefer crypto? Use the QR code or wallet address below.
</p>
</div>
<div className="flex h-10 w-full items-center justify-between gap-2 rounded-lg border bg-muted/50 py-1.5 pl-3 pr-1.5">
<code className="truncate text-xs font-mono text-muted-foreground" title="THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs">
THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs
</code>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 hover:bg-background" onClick={() => {
navigator.clipboard.writeText("THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs");
setCopiedUsdt(true);
setTimeout(() => setCopiedUsdt(false), 500);
}}>
{copiedUsdt ? <CircleCheck className="h-3.5 w-3.5 text-green-500"/> : <Copy className="h-3.5 w-3.5"/>}
</Button>
</div>
</div>
</div>
<div className="mt-4 w-full max-w-5xl rounded-xl border bg-muted/30 px-4 py-3 text-center text-sm text-muted-foreground">
If you have any questions or need help with donating, feel free to reach out via{" "}
<button type="button" className="font-medium text-foreground underline-offset-4 hover:underline" onClick={() => openExternal("https://t.me/afkarxyz")}>
Telegram
</button>{" "}
or{" "}
<button type="button" className="font-medium text-foreground underline-offset-4 hover:underline" onClick={() => {
navigator.clipboard.writeText("hi@afkarxyz.fyi");
setCopiedEmail(true);
setTimeout(() => setCopiedEmail(false), 500);
}}>
{copiedEmail ? "copied" : "hi@afkarxyz.fyi"}
</button>
.
</div>
</div>
</div>);
}
+1 -1
View File
@@ -176,7 +176,7 @@ export function TitleBar() {
</div>)} </div>)}
</div> </div>
<MenubarSeparator /> <MenubarSeparator />
<MenubarItem onClick={() => openExternal("https://afkarxyz.qzz.io")} className="gap-2"> <MenubarItem onClick={() => openExternal("https://afkarxyz.fyi")} className="gap-2">
<Globe className="w-4 h-4 opacity-70"/> <Globe className="w-4 h-4 opacity-70"/>
<span>Website</span> <span>Website</span>
</MenubarItem> </MenubarItem>
@@ -1,61 +0,0 @@
"use client";
import type { Variants } from "motion/react";
import { motion, useAnimation } from "motion/react";
import type { HTMLAttributes } from "react";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { cn } from "@/lib/utils";
export interface BadgeAlertIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface BadgeAlertIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const ICON_VARIANTS: Variants = {
normal: { scale: 1, rotate: 0 },
animate: {
scale: [1, 1.1, 1.1, 1.1, 1],
rotate: [0, -3, 3, -2, 2, 0],
transition: {
duration: 0.5,
times: [0, 0.2, 0.4, 0.6, 1],
ease: "easeInOut",
},
},
};
const BadgeAlertIcon = forwardRef<BadgeAlertIconHandle, BadgeAlertIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start("animate"),
stopAnimation: () => controls.start("normal"),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseEnter?.(e);
}
else {
controls.start("animate");
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseLeave?.(e);
}
else {
controls.start("normal");
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<motion.svg animate={controls} fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" variants={ICON_VARIANTS} viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
<path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/>
<line x1="12" x2="12" y1="8" y2="12"/>
<line x1="12" x2="12.01" y1="16" y2="16"/>
</motion.svg>
</div>);
});
BadgeAlertIcon.displayName = "BadgeAlertIcon";
export { BadgeAlertIcon };
@@ -0,0 +1,132 @@
"use client";
import type { Transition, Variants } from "motion/react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState, type HTMLAttributes } from "react";
import { cn } from "@/lib/utils";
type ReportIconMode = "bug" | "bulb";
interface BugReportIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
loop?: boolean;
}
const LOOP_INTERVAL_MS = 2200;
const GROUP_VARIANTS: Variants = {
hidden: {
opacity: 0,
},
visible: {
opacity: 1,
transition: {
duration: 0.2,
ease: [0, 0, 0.2, 1],
},
},
exit: {
opacity: 0,
transition: {
duration: 0.18,
ease: [0.4, 0, 1, 1],
},
},
};
const DRAW_VARIANTS: Variants = {
hidden: {
pathLength: 0,
opacity: 0,
},
visible: {
pathLength: 1,
opacity: 1,
},
exit: {
pathLength: 1,
opacity: 0,
},
};
function createDrawTransition(delay = 0, duration = 0.36): Transition {
return {
duration,
delay,
ease: [0.4, 0, 0.2, 1],
opacity: { delay },
};
}
function BugPaths() {
return (<>
<motion.path d="m8 2 1.88 1.88" transition={createDrawTransition(0)} variants={DRAW_VARIANTS}/>
<motion.path d="M14.12 3.88 16 2" transition={createDrawTransition(0.04)} variants={DRAW_VARIANTS}/>
<motion.path d="M9 7.13V6a3 3 0 1 1 6 0v1.13" transition={createDrawTransition(0.08)} variants={DRAW_VARIANTS}/>
<motion.path d="M6.53 9A4 4 0 0 1 3 5" transition={createDrawTransition(0.14)} variants={DRAW_VARIANTS}/>
<motion.path d="M17.47 9A4 4 0 0 0 21 5" transition={createDrawTransition(0.18)} variants={DRAW_VARIANTS}/>
<motion.path d="M12 20v-9" transition={createDrawTransition(0.24)} variants={DRAW_VARIANTS}/>
<motion.path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" transition={createDrawTransition(0.3, 0.42)} variants={DRAW_VARIANTS}/>
<motion.path d="M22 13h-4" transition={createDrawTransition(0.42)} variants={DRAW_VARIANTS}/>
<motion.path d="M6 13H2" transition={createDrawTransition(0.46)} variants={DRAW_VARIANTS}/>
<motion.path d="M21 21a4 4 0 0 0-3.81-4" transition={createDrawTransition(0.52)} variants={DRAW_VARIANTS}/>
<motion.path d="M3 21a4 4 0 0 1 3.81-4" transition={createDrawTransition(0.56)} variants={DRAW_VARIANTS}/>
</>);
}
function BulbPaths() {
return (<>
<motion.path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5" transition={createDrawTransition(0, 0.46)} variants={DRAW_VARIANTS}/>
<motion.path d="M9 18h6" transition={createDrawTransition(0.16)} variants={DRAW_VARIANTS}/>
<motion.path d="M10 22h4" transition={createDrawTransition(0.24)} variants={DRAW_VARIANTS}/>
</>);
}
function ReportIconGroup({ mode }: { mode: ReportIconMode }) {
return (<motion.g animate="visible" exit="exit" initial="hidden" variants={GROUP_VARIANTS}>
{mode === "bug" ? <BugPaths/> : <BulbPaths/>}
</motion.g>);
}
function StaticBugIcon() {
return (<g>
<path d="m8 2 1.88 1.88"/>
<path d="M14.12 3.88 16 2"/>
<path d="M9 7.13V6a3 3 0 1 1 6 0v1.13"/>
<path d="M6.53 9A4 4 0 0 1 3 5"/>
<path d="M17.47 9A4 4 0 0 0 21 5"/>
<path d="M12 20v-9"/>
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z"/>
<path d="M22 13h-4"/>
<path d="M6 13H2"/>
<path d="M21 21a4 4 0 0 0-3.81-4"/>
<path d="M3 21a4 4 0 0 1 3.81-4"/>
</g>);
}
function BugReportIcon({ className, size = 28, loop = false, ...props }: BugReportIconProps) {
const [mode, setMode] = useState<ReportIconMode>("bug");
useEffect(() => {
if (!loop) {
setMode("bug");
return;
}
const intervalId = window.setInterval(() => {
setMode((currentMode) => currentMode === "bug" ? "bulb" : "bug");
}, LOOP_INTERVAL_MS);
return () => window.clearInterval(intervalId);
}, [loop]);
return (<div className={cn("flex items-center justify-center", className)} {...props}>
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
{loop ? (<AnimatePresence>
<ReportIconGroup key={mode} mode={mode}/>
</AnimatePresence>) : (<StaticBugIcon/>)}
</svg>
</div>);
}
export { BugReportIcon };
-102
View File
@@ -1,102 +0,0 @@
"use client";
import type { Variants } from "motion/react";
import { motion, useAnimation } from "motion/react";
import type { HTMLAttributes } from "react";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { cn } from "@/lib/utils";
export interface GithubIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface GithubIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const BODY_VARIANTS: Variants = {
normal: {
opacity: 1,
pathLength: 1,
scale: 1,
transition: {
duration: 0.3,
},
},
animate: {
opacity: [0, 1],
pathLength: [0, 1],
scale: [0.9, 1],
transition: {
duration: 0.4,
},
},
};
const TAIL_VARIANTS: Variants = {
normal: {
pathLength: 1,
rotate: 0,
transition: {
duration: 0.3,
},
},
draw: {
pathLength: [0, 1],
rotate: 0,
transition: {
duration: 0.5,
},
},
wag: {
pathLength: 1,
rotate: [0, -15, 15, -10, 10, -5, 5],
transition: {
duration: 2.5,
ease: "easeInOut",
repeat: Number.POSITIVE_INFINITY,
},
},
};
const GithubIcon = forwardRef<GithubIconHandle, GithubIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const bodyControls = useAnimation();
const tailControls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: async () => {
bodyControls.start("animate");
await tailControls.start("draw");
tailControls.start("wag");
},
stopAnimation: () => {
bodyControls.start("normal");
tailControls.start("normal");
},
};
});
const handleMouseEnter = useCallback(async (e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseEnter?.(e);
}
else {
bodyControls.start("animate");
await tailControls.start("draw");
tailControls.start("wag");
}
}, [bodyControls, onMouseEnter, tailControls]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseLeave?.(e);
}
else {
bodyControls.start("normal");
tailControls.start("normal");
}
}, [bodyControls, tailControls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
<motion.path animate={bodyControls} d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" initial="normal" variants={BODY_VARIANTS}/>
<motion.path animate={tailControls} d="M9 18c-4.51 2-5-2-7-2" initial="normal" variants={TAIL_VARIANTS}/>
</svg>
</div>);
});
GithubIcon.displayName = "GithubIcon";
export { GithubIcon };
+89
View File
@@ -0,0 +1,89 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface ToolCaseIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface ToolCaseIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const DRAW_VARIANTS: Variants = {
normal: {
pathLength: 1,
opacity: 1,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
transition: {
duration: 0.6,
ease: 'easeInOut',
},
},
};
const HANDLE_VARIANTS: Variants = {
normal: {
scaleX: 1,
originX: '50%',
},
animate: {
scaleX: [0.6, 1.1, 1],
originX: '50%',
transition: {
duration: 0.45,
ease: 'easeInOut',
},
},
};
const ToolCaseIcon = forwardRef<ToolCaseIconHandle, ToolCaseIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
}
else {
onMouseEnter?.(e);
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
}
else {
onMouseLeave?.(e);
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<motion.path d="M10 15h4" variants={HANDLE_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="m14.817 10.995-.971-1.45 1.034-1.232a2 2 0 0 0-2.025-3.238l-1.82.364L9.91 3.885a2 2 0 0 0-3.625.748L6.141 6.55l-1.725.426a2 2 0 0 0-.19 3.756l.657.27" variants={DRAW_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="m18.822 10.995 2.26-5.38a1 1 0 0 0-.557-1.318L16.954 2.9a1 1 0 0 0-1.281.533l-.924 2.122" variants={DRAW_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="M4 12.006A1 1 0 0 1 4.994 11H19a1 1 0 0 1 1 1v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" variants={DRAW_VARIANTS} animate={controls} initial="normal"/>
</svg>
</div>);
});
ToolCaseIcon.displayName = 'ToolCaseIcon';
export { ToolCaseIcon };
+3 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; 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() { export function useApiStatus() {
const [state, setState] = useState(getApiStatusState); const [state, setState] = useState(getApiStatusState);
useEffect(() => { useEffect(() => {
@@ -11,5 +11,7 @@ export function useApiStatus() {
...state, ...state,
sources: API_SOURCES, sources: API_SOURCES,
checkOne: (sourceId: string) => checkApiStatus(sourceId), checkOne: (sourceId: string) => checkApiStatus(sourceId),
checkAllCurrent: () => checkCurrentApiStatusesOnly(),
checkAllNext: () => checkSpotiFLACNextStatusesOnly(),
}; };
} }
+8 -6
View File
@@ -1,6 +1,6 @@
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { downloadTrack, fetchSpotifyMetadata } from "@/lib/api"; import { downloadTrack, fetchSpotifyMetadata } from "@/lib/api";
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings"; import { getSettings, hasConfiguredCustomTidalApi, parseTemplate, sanitizeAutoOrder, type TemplateData } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils"; import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
@@ -86,10 +86,11 @@ export function useDownload(region: string) {
setDownloadRemainingCount(Math.max(0, safeTotalCount - safeCompletedCount)); 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 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 query = trackName && artistName ? `${trackName} ${artistName} ` : undefined;
const os = settings.operatingSystem; 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, "") ? settings.customTidalApi.trim().replace(/\/+$/g, "")
: undefined; : undefined;
let outputDir = settings.downloadPath; let outputDir = settings.downloadPath;
@@ -193,7 +194,7 @@ export function useDownload(region: string) {
itemID = await AddToDownloadQueue(id, trackName || "", displayArtist || "", albumName || ""); itemID = await AddToDownloadQueue(id, trackName || "", displayArtist || "", albumName || "");
} }
if (service === "auto") { if (service === "auto") {
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-"); const order = sanitizeAutoOrder(settings.autoOrder, allowTidal).split("-");
let streamingURLs: any = null; let streamingURLs: any = null;
if (spotifyId && shouldFetchStreamingURLs(order)) { if (spotifyId && shouldFetchStreamingURLs(order)) {
try { try {
@@ -416,7 +417,8 @@ export function useDownload(region: string) {
return singleServiceResponse; 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 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 query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
const os = settings.operatingSystem; const os = settings.operatingSystem;
let outputDir = settings.downloadPath; let outputDir = settings.downloadPath;
@@ -477,7 +479,7 @@ export function useDownload(region: string) {
} }
} }
if (service === "auto") { if (service === "auto") {
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-"); const order = sanitizeAutoOrder(settings.autoOrder, allowTidal).split("-");
let streamingURLs: any = null; let streamingURLs: any = null;
if (spotifyId && shouldFetchStreamingURLs(order)) { if (spotifyId && shouldFetchStreamingURLs(order)) {
try { try {
+234 -58
View File
@@ -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 { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout";
import { getSettings, hasConfiguredCustomTidalApi } from "@/lib/settings";
export type ApiCheckStatus = "checking" | "online" | "offline" | "idle"; export type ApiCheckStatus = "checking" | "online" | "offline" | "idle";
export interface ApiSource { export interface ApiSource {
id: string; id: string;
type: string; type: string;
name: string; name: string;
url: string; url: string;
} }
interface SpotiFLACNextSource { interface SpotiFLACNextSource {
id: string; id: string;
name: string; name: string;
statusKey?: string; statusKey?: string;
statusPrefix?: string; statusPrefix?: string;
} }
type SpotiFLACNextStatusResponse = Partial<Record<string, string>>; type SpotiFLACNextStatusResponse = Partial<Record<string, string>>;
type ApiStatusTargetReport = {
target?: string;
label?: string;
online?: boolean;
message?: string;
};
type ApiStatusReport = {
type?: string;
online?: boolean;
require_all?: boolean;
details?: ApiStatusTargetReport[];
};
export const API_SOURCES: ApiSource[] = [ export const API_SOURCES: ApiSource[] = [
{ id: "tidal", type: "tidal", name: "Tidal", url: "" }, { id: "tidal", type: "tidal", name: "Tidal", url: "" },
{ id: "qobuz", type: "qobuz", name: "Qobuz", url: "" }, { id: "qobuz", type: "qobuz", name: "Qobuz", url: "" },
{ id: "amazon", type: "amazon", name: "Amazon Music", 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[] = [ export const SPOTIFLAC_NEXT_SOURCES: SpotiFLACNextSource[] = [
{ id: "tidal", name: "Tidal", statusKey: "tidal" }, { id: "tidal", name: "Tidal", statusKey: "tidal" },
{ id: "qobuz", name: "Qobuz", statusPrefix: "qobuz_" }, { id: "qobuz", name: "Qobuz", statusPrefix: "qobuz_" },
@@ -27,43 +45,101 @@ export const SPOTIFLAC_NEXT_SOURCES: SpotiFLACNextSource[] = [
{ id: "deezer", name: "Deezer", statusPrefix: "deezer_" }, { id: "deezer", name: "Deezer", statusPrefix: "deezer_" },
{ id: "apple", name: "Apple Music", statusKey: "apple" }, { 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_STATUS_URL = "https://gist.githubusercontent.com/afkarxyz/6e57cd362cbd67f889e3a91a76254a5e/raw";
const SPOTIFLAC_NEXT_RETRY_DELAY_MS = 1200; const SPOTIFLAC_CURRENT_AMAZON_STATUS_KEY = "amazon_a";
const SPOTIFLAC_STATUS_MAX_ATTEMPTS = 3;
const SPOTIFLAC_STATUS_RETRY_DELAY_MS = 1200;
const CheckAPIStatusReport = (apiType: string, apiURL: string): Promise<ApiStatusReport> => (window as any)["go"]["main"]["App"]["CheckAPIStatusReport"](apiType, apiURL);
const LogStatusConsole = (level: string, message: string): Promise<void> => (window as any)["go"]["main"]["App"]["LogStatusConsole"](level, message);
type ApiStatusState = { type ApiStatusState = {
checkingSources: Record<string, boolean>; checkingSources: Record<string, boolean>;
statuses: Record<string, ApiCheckStatus>; statuses: Record<string, ApiCheckStatus>;
nextStatuses: Record<string, ApiCheckStatus>; nextStatuses: Record<string, ApiCheckStatus>;
}; };
let apiStatusState: ApiStatusState = { let apiStatusState: ApiStatusState = {
checkingSources: {}, checkingSources: {},
statuses: {}, statuses: {},
nextStatuses: {}, nextStatuses: {},
}; };
let activeCheckCurrentOnly: Promise<void> | null = null;
let activeCheckNextOnly: Promise<void> | null = null; let activeCheckNextOnly: Promise<void> | null = null;
let activeStatusPayloadFetch: Promise<SpotiFLACNextStatusResponse> | null = null;
const activeSourceChecks = new Map<string, Promise<void>>(); const activeSourceChecks = new Map<string, Promise<void>>();
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
function emitApiStatusChange() { function emitApiStatusChange() {
for (const listener of listeners) { for (const listener of listeners) {
listener(); listener();
} }
} }
function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState) { function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState) {
apiStatusState = updater(apiStatusState); apiStatusState = updater(apiStatusState);
emitApiStatusChange(); emitApiStatusChange();
} }
async function checkSourceStatus(source: ApiSource): Promise<ApiCheckStatus> {
function delay(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function sendStatusConsole(level: "info" | "warning" | "error", message: string): void {
try { try {
const isOnline = await withTimeout(CheckAPIStatus(source.type, source.url), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.name}`); void LogStatusConsole(level, message);
return isOnline ? "online" : "offline";
} }
catch { catch {
return "offline"; return;
} }
} }
function logStatusInfo(message: string): void {
sendStatusConsole("info", message);
}
function logStatusWarning(message: string): void {
sendStatusConsole("warning", message);
}
function logStatusError(message: string): void {
sendStatusConsole("error", message);
}
function truncateStatusMessage(message?: string, maxLen = 180): string {
const trimmed = (message || "").trim();
if (trimmed.length <= maxLen) {
return trimmed;
}
return trimmed.slice(0, maxLen) + "...";
}
function logQobuzStatusReport(report: ApiStatusReport): void {
const details = Array.isArray(report.details) ? report.details : [];
if (details.length === 0) {
logStatusWarning("[Status][Qobuz] No provider details were returned.");
return;
}
const onlineCount = details.filter((detail) => detail.online === true).length;
logStatusInfo(`[Status][Qobuz] Provider check completed: ${onlineCount}/${details.length} providers online.`);
for (const detail of details) {
const label = detail.label || detail.target || "Unknown provider";
const suffix = detail.message ? ` - ${truncateStatusMessage(detail.message)}` : "";
if (detail.online) {
logStatusInfo(`[Status][Qobuz] ${label}: online${suffix}`);
}
else {
logStatusWarning(`[Status][Qobuz] ${label}: offline${suffix}`);
}
}
if (report.online) {
logStatusInfo(`[Status][Qobuz] SpotiFLAC Qobuz is online (${onlineCount}/${details.length} providers online).`);
}
else {
logStatusWarning(`[Status][Qobuz] SpotiFLAC Qobuz marked maintenance because all ${details.length} providers are offline.`);
}
}
function anyNextVariantUp(values: Array<string | undefined>): ApiCheckStatus { function anyNextVariantUp(values: Array<string | undefined>): ApiCheckStatus {
return values.some((value) => value === "up") ? "online" : "offline"; return values.some((value) => value === "up") ? "online" : "offline";
} }
function getNextSourceValues(payload: SpotiFLACNextStatusResponse, source: SpotiFLACNextSource): string[] { function getNextSourceValues(payload: SpotiFLACNextStatusResponse, source: SpotiFLACNextSource): string[] {
if (source.statusKey) { if (source.statusKey) {
const value = payload[source.statusKey]; const value = payload[source.statusKey];
@@ -80,9 +156,11 @@ function getNextSourceValues(payload: SpotiFLACNextStatusResponse, source: Spoti
} }
return values; return values;
} }
function delay(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms)); function getCurrentAmazonStatus(payload: SpotiFLACNextStatusResponse): ApiCheckStatus {
return payload[SPOTIFLAC_CURRENT_AMAZON_STATUS_KEY] === "up" ? "online" : "offline";
} }
function getSafeNextStatusesFallback(currentStatuses: Record<string, ApiCheckStatus>): Record<string, ApiCheckStatus> { function getSafeNextStatusesFallback(currentStatuses: Record<string, ApiCheckStatus>): Record<string, ApiCheckStatus> {
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => { return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
const current = currentStatuses[source.id]; const current = currentStatuses[source.id];
@@ -90,57 +168,142 @@ function getSafeNextStatusesFallback(currentStatuses: Record<string, ApiCheckSta
return acc; return acc;
}, {}); }, {});
} }
async function fetchSpotiFLACNextStatusesOnce(): Promise<Record<string, ApiCheckStatus>> {
const response = await withTimeout(fetch(SPOTIFLAC_NEXT_STATUS_URL, { function hasCurrentResults(): boolean {
method: "GET", return API_SOURCES.some((source) => {
cache: "no-store", const status = apiStatusState.statuses[source.id];
headers: { return status === "online" || status === "offline";
Accept: "application/json", });
},
}), CHECK_TIMEOUT_MS, "SpotiFLAC Next status check timed out after 10 seconds");
if (!response.ok) {
throw new Error(`SpotiFLAC Next status returned ${response.status}`);
}
const payload = (await response.json()) as SpotiFLACNextStatusResponse;
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
acc[source.id] = anyNextVariantUp(getNextSourceValues(payload, source));
return acc;
}, {});
}
async function checkSpotiFLACNextStatuses(): Promise<Record<string, ApiCheckStatus>> {
let lastError: unknown = null;
for (let attempt = 1; attempt <= SPOTIFLAC_NEXT_MAX_ATTEMPTS; attempt++) {
try {
return await fetchSpotiFLACNextStatusesOnce();
}
catch (error) {
lastError = error;
if (attempt < SPOTIFLAC_NEXT_MAX_ATTEMPTS) {
await delay(SPOTIFLAC_NEXT_RETRY_DELAY_MS * attempt);
}
}
}
throw lastError instanceof Error ? lastError : new Error("SpotiFLAC Next status check failed");
}
export function getApiStatusState(): ApiStatusState {
return apiStatusState;
}
export function subscribeApiStatus(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
} }
function hasSpotiFLACNextResults(): boolean { function hasSpotiFLACNextResults(): boolean {
return SPOTIFLAC_NEXT_SOURCES.some((source) => { return SPOTIFLAC_NEXT_SOURCES.some((source) => {
const status = apiStatusState.nextStatuses[source.id]; const status = apiStatusState.nextStatuses[source.id];
return status === "online" || status === "offline"; return status === "online" || status === "offline";
}); });
} }
async function fetchSpotiFLACStatusPayloadOnce(): Promise<SpotiFLACNextStatusResponse> {
const response = await withTimeout(fetch(SPOTIFLAC_STATUS_URL, {
method: "GET",
cache: "no-store",
headers: {
Accept: "application/json",
},
}), CHECK_TIMEOUT_MS, "SpotiFLAC status check timed out after 10 seconds");
if (!response.ok) {
throw new Error(`SpotiFLAC status returned ${response.status}`);
}
return (await response.json()) as SpotiFLACNextStatusResponse;
}
async function fetchSpotiFLACStatusPayload(): Promise<SpotiFLACNextStatusResponse> {
if (activeStatusPayloadFetch) {
return activeStatusPayloadFetch;
}
activeStatusPayloadFetch = (async () => {
let lastError: unknown = null;
for (let attempt = 1; attempt <= SPOTIFLAC_STATUS_MAX_ATTEMPTS; attempt++) {
try {
return await fetchSpotiFLACStatusPayloadOnce();
}
catch (error) {
lastError = error;
if (attempt < SPOTIFLAC_STATUS_MAX_ATTEMPTS) {
await delay(SPOTIFLAC_STATUS_RETRY_DELAY_MS * attempt);
}
}
}
throw lastError instanceof Error ? lastError : new Error("SpotiFLAC status check failed");
})();
try {
return await activeStatusPayloadFetch;
}
finally {
activeStatusPayloadFetch = null;
}
}
async function checkSourceStatus(source: ApiSource): Promise<ApiCheckStatus> {
try {
if (source.id === "tidal") {
const customTidalApi = getSettings().customTidalApi;
if (!hasConfiguredCustomTidalApi(customTidalApi)) {
logStatusWarning("[Status][Tidal] Marked maintenance because no custom Tidal instance is configured.");
return "offline";
}
const isOnline = await withTimeout(CheckCustomTidalAPI(customTidalApi), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.name}`);
return isOnline ? "online" : "offline";
}
if (source.id === "amazon") {
const payload = await fetchSpotiFLACStatusPayload();
return getCurrentAmazonStatus(payload);
}
if (source.id === "qobuz") {
logStatusInfo("[Status][Qobuz] Checking current SpotiFLAC providers...");
const report = await withTimeout(CheckAPIStatusReport(source.type, source.url), CHECK_TIMEOUT_MS, `API status report timed out after 10 seconds for ${source.name}`);
logQobuzStatusReport(report);
return report.online ? "online" : "offline";
}
const isOnline = await withTimeout(CheckAPIStatus(source.type, source.url), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.name}`);
return isOnline ? "online" : "offline";
}
catch (error) {
if (source.id === "qobuz") {
logStatusError(`[Status][Qobuz] Provider check failed: ${error instanceof Error ? error.message : String(error)}`);
}
return "offline";
}
}
async function checkSpotiFLACNextStatuses(): Promise<Record<string, ApiCheckStatus>> {
const payload = await fetchSpotiFLACStatusPayload();
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
acc[source.id] = anyNextVariantUp(getNextSourceValues(payload, source));
return acc;
}, {});
}
export function getApiStatusState(): ApiStatusState {
return apiStatusState;
}
export function subscribeApiStatus(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export async function checkCurrentApiStatusesOnly(): Promise<void> {
if (activeCheckCurrentOnly) {
return activeCheckCurrentOnly;
}
activeCheckCurrentOnly = (async () => {
await Promise.all(API_SOURCES.map((source) => checkApiStatus(source.id)));
})();
try {
await activeCheckCurrentOnly;
}
finally {
activeCheckCurrentOnly = null;
}
}
export async function checkSpotiFLACNextStatusesOnly(): Promise<void> { export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
if (activeCheckNextOnly) { if (activeCheckNextOnly) {
return activeCheckNextOnly; return activeCheckNextOnly;
} }
activeCheckNextOnly = (async () => { activeCheckNextOnly = (async () => {
const checkingNextStatuses = Object.fromEntries(SPOTIFLAC_NEXT_SOURCES.map((source) => [source.id, "checking" as ApiCheckStatus])); const checkingNextStatuses = Object.fromEntries(SPOTIFLAC_NEXT_SOURCES.map((source) => [source.id, "checking" as ApiCheckStatus]));
setApiStatusState((current) => ({ setApiStatusState((current) => ({
@@ -150,11 +313,8 @@ export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
...checkingNextStatuses, ...checkingNextStatuses,
}, },
})); }));
try { try {
setApiStatusState((current) => ({
...current,
nextStatuses: { ...current.nextStatuses },
}));
const nextStatuses = await checkSpotiFLACNextStatuses(); const nextStatuses = await checkSpotiFLACNextStatuses();
setApiStatusState((current) => ({ setApiStatusState((current) => ({
...current, ...current,
@@ -170,26 +330,40 @@ export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
nextStatuses: getSafeNextStatusesFallback(current.nextStatuses), nextStatuses: getSafeNextStatusesFallback(current.nextStatuses),
})); }));
} }
})();
try {
await activeCheckNextOnly;
}
finally { finally {
activeCheckNextOnly = null; activeCheckNextOnly = null;
} }
})();
return activeCheckNextOnly;
} }
export function ensureSpotiFLACNextStatusCheckStarted(): void {
export function ensureApiStatusCheckStarted(): void {
if (!activeCheckCurrentOnly && !hasCurrentResults()) {
void checkCurrentApiStatusesOnly();
}
if (!activeCheckNextOnly && !hasSpotiFLACNextResults()) { if (!activeCheckNextOnly && !hasSpotiFLACNextResults()) {
void checkSpotiFLACNextStatusesOnly(); void checkSpotiFLACNextStatusesOnly();
} }
} }
export function ensureSpotiFLACNextStatusCheckStarted(): void {
ensureApiStatusCheckStarted();
}
export async function checkApiStatus(sourceId: string): Promise<void> { export async function checkApiStatus(sourceId: string): Promise<void> {
const source = API_SOURCES.find((item) => item.id === sourceId); const source = API_SOURCES.find((item) => item.id === sourceId);
if (!source) { if (!source) {
return; return;
} }
const activeCheck = activeSourceChecks.get(sourceId); const activeCheck = activeSourceChecks.get(sourceId);
if (activeCheck) { if (activeCheck) {
return activeCheck; return activeCheck;
} }
const task = (async () => { const task = (async () => {
setApiStatusState((current) => ({ setApiStatusState((current) => ({
...current, ...current,
@@ -202,6 +376,7 @@ export async function checkApiStatus(sourceId: string): Promise<void> {
[sourceId]: "checking", [sourceId]: "checking",
}, },
})); }));
try { try {
const status = await checkSourceStatus(source); const status = await checkSourceStatus(source);
setApiStatusState((current) => ({ setApiStatusState((current) => ({
@@ -223,6 +398,7 @@ export async function checkApiStatus(sourceId: string): Promise<void> {
activeSourceChecks.delete(sourceId); activeSourceChecks.delete(sourceId);
} }
})(); })();
activeSourceChecks.set(sourceId, task); activeSourceChecks.set(sourceId, task);
return task; return task;
} }
+32 -2
View File
@@ -185,7 +185,7 @@ export const DEFAULT_SETTINGS: Settings = {
tidalQuality: "LOSSLESS", tidalQuality: "LOSSLESS",
qobuzQuality: "6", qobuzQuality: "6",
amazonQuality: "original", amazonQuality: "original",
autoOrder: "tidal-qobuz-amazon", autoOrder: "qobuz-amazon",
autoQuality: "16", autoQuality: "16",
allowFallback: true, allowFallback: true,
createPlaylistFolder: true, createPlaylistFolder: true,
@@ -521,6 +521,33 @@ function normalizeCustomTidalApi(value: unknown): string {
? value.trim().replace(/\/+$/g, "") ? 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 { function normalizeExistingFileCheckMode(mode: unknown): ExistingFileCheckMode {
switch (typeof mode === "string" ? mode.trim().toLowerCase() : "") { switch (typeof mode === "string" ? mode.trim().toLowerCase() : "") {
case "isrc": case "isrc":
@@ -583,12 +610,15 @@ function normalizeSettingsPayload(settings: SettingsPayload): SettingsPayload {
normalized.amazonQuality = "original"; normalized.amazonQuality = "original";
} }
if (!("autoOrder" in normalized)) { if (!("autoOrder" in normalized)) {
normalized.autoOrder = "tidal-qobuz-amazon"; normalized.autoOrder = DEFAULT_SETTINGS.autoOrder;
} }
if (!("autoQuality" in normalized)) { if (!("autoQuality" in normalized)) {
normalized.autoQuality = "16"; normalized.autoQuality = "16";
} }
normalized.customTidalApi = normalizeCustomTidalApi(normalized.customTidalApi); 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)) { if (!("allowFallback" in normalized)) {
normalized.allowFallback = true; normalized.allowFallback = true;
} }
+2 -1
View File
@@ -9,13 +9,14 @@ require (
github.com/go-flac/go-flac v1.0.0 github.com/go-flac/go-flac v1.0.0
github.com/pquerna/otp v1.5.0 github.com/pquerna/otp v1.5.0
github.com/ulikunitz/xz v0.5.15 github.com/ulikunitz/xz v0.5.15
github.com/wailsapp/wails/v2 v2.11.0 github.com/wailsapp/wails/v2 v2.12.0
go.etcd.io/bbolt v1.4.3 go.etcd.io/bbolt v1.4.3
golang.org/x/image v0.12.0 golang.org/x/image v0.12.0
golang.org/x/text v0.31.0 golang.org/x/text v0.31.0
) )
require ( require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // indirect github.com/bep/debounce v1.2.1 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
+4 -2
View File
@@ -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 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI= 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/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 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= 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.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k= 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= 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 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
+1 -1
View File
@@ -12,7 +12,7 @@
}, },
"info": { "info": {
"productName": "SpotiFLAC", "productName": "SpotiFLAC",
"productVersion": "7.1.6", "productVersion": "7.1.7",
"copyright": "© 2026 afkarxyz" "copyright": "© 2026 afkarxyz"
}, },
"wailsjsdir": "./frontend", "wailsjsdir": "./frontend",