v7.1.1
This commit is contained in:
@@ -1 +1,2 @@
|
|||||||
ko_fi: afkarxyz
|
ko_fi: afkarxyz
|
||||||
|
patreon: afkarxyz
|
||||||
@@ -24,14 +24,12 @@ Get Spotify tracks in true Lossless from Tidal, Qobuz, Amazon Music, Deezer & Ap
|
|||||||
|
|
||||||
Download Spotify Tracks, Albums, Playlists & Discography as MP3/OGG/Opus.
|
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]
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
type qobuzDownloadProvider interface {
|
||||||
|
Name() string
|
||||||
|
Attempts(trackID int64, quality string) []qobuzProviderAttempt
|
||||||
|
}
|
||||||
|
|
||||||
|
type qobuzProviderAttempt struct {
|
||||||
|
Name string
|
||||||
|
ID string
|
||||||
|
Download func() (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type QobuzProviderWJHE struct {
|
||||||
|
downloader *QobuzDownloader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p QobuzProviderWJHE) Name() string {
|
||||||
|
return "QobuzProviderWJHE"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p QobuzProviderWJHE) Attempts(trackID int64, quality string) []qobuzProviderAttempt {
|
||||||
|
return []qobuzProviderAttempt{
|
||||||
|
{
|
||||||
|
Name: p.Name(),
|
||||||
|
ID: GetQobuzWJHEStreamAPIURL(),
|
||||||
|
Download: func() (string, error) {
|
||||||
|
return p.downloader.DownloadFromWJHE(trackID, quality)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type QobuzProviderMusicDL struct {
|
||||||
|
downloader *QobuzDownloader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p QobuzProviderMusicDL) Name() string {
|
||||||
|
return "QobuzProviderMusicDL"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p QobuzProviderMusicDL) Attempts(trackID int64, quality string) []qobuzProviderAttempt {
|
||||||
|
return []qobuzProviderAttempt{
|
||||||
|
{
|
||||||
|
Name: p.Name(),
|
||||||
|
ID: GetQobuzMusicDLDownloadAPIURL(),
|
||||||
|
Download: func() (string, error) {
|
||||||
|
return p.downloader.DownloadFromMusicDL(trackID, quality)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type QobuzProviderGDStudio struct {
|
||||||
|
downloader *QobuzDownloader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p QobuzProviderGDStudio) Name() string {
|
||||||
|
return "QobuzProviderGDStudio"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p QobuzProviderGDStudio) Attempts(trackID int64, quality string) []qobuzProviderAttempt {
|
||||||
|
attempts := make([]qobuzProviderAttempt, 0, len(GetQobuzGDStudioAPIURLs()))
|
||||||
|
for _, apiURL := range GetQobuzGDStudioAPIURLs() {
|
||||||
|
currentAPIURL := apiURL
|
||||||
|
attempts = append(attempts, qobuzProviderAttempt{
|
||||||
|
Name: p.Name(),
|
||||||
|
ID: currentAPIURL,
|
||||||
|
Download: func() (string, error) {
|
||||||
|
return p.downloader.DownloadFromGDStudio(trackID, quality, currentAPIURL)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return attempts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QobuzDownloader) getQobuzDownloadProviders() []qobuzDownloadProvider {
|
||||||
|
return []qobuzDownloadProvider{
|
||||||
|
QobuzProviderWJHE{downloader: q},
|
||||||
|
QobuzProviderGDStudio{downloader: q},
|
||||||
|
QobuzProviderMusicDL{downloader: q},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveQobuzAttemptIDsLast(providerIDs []string, lastIDs ...string) []string {
|
||||||
|
if len(providerIDs) == 0 || len(lastIDs) == 0 {
|
||||||
|
return append([]string(nil), providerIDs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIDSet := make(map[string]struct{}, len(lastIDs))
|
||||||
|
for _, providerID := range lastIDs {
|
||||||
|
lastIDSet[providerID] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
ordered := make([]string, 0, len(providerIDs))
|
||||||
|
trailing := make([]string, 0, len(providerIDs))
|
||||||
|
for _, providerID := range providerIDs {
|
||||||
|
if _, ok := lastIDSet[providerID]; ok {
|
||||||
|
trailing = append(trailing, providerID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ordered = append(ordered, providerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return append(ordered, trailing...)
|
||||||
|
}
|
||||||
+7
-46
@@ -50,28 +50,12 @@ type TidalBTSManifest struct {
|
|||||||
|
|
||||||
func getConfiguredTidalAPIAttemptList() ([]string, error) {
|
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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,296 +0,0 @@
|
|||||||
package backend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
tidalAPIListGistURL = "https://gist.githubusercontent.com/afkarxyz/2ce772b943321b9448b454f39403ce25/raw"
|
|
||||||
tidalAPIListCacheFile = "tidal-api-urls.json"
|
|
||||||
)
|
|
||||||
|
|
||||||
type tidalAPIListCache struct {
|
|
||||||
URLs []string `json:"urls"`
|
|
||||||
LastUsedURL string `json:"last_used_url,omitempty"`
|
|
||||||
UpdatedAt int64 `json:"updated_at_unix"`
|
|
||||||
Source string `json:"source,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
tidalAPIListMu sync.Mutex
|
|
||||||
tidalAPIListState *tidalAPIListCache
|
|
||||||
)
|
|
||||||
|
|
||||||
func loadTidalAPIListStateLocked() (*tidalAPIListCache, error) {
|
|
||||||
if tidalAPIListState != nil {
|
|
||||||
return cloneTidalAPIListState(tidalAPIListState), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
appDir, err := EnsureAppDir()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
cachePath := filepath.Join(appDir, tidalAPIListCacheFile)
|
|
||||||
data, err := os.ReadFile(cachePath)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
state := &tidalAPIListCache{}
|
|
||||||
tidalAPIListState = cloneTidalAPIListState(state)
|
|
||||||
return cloneTidalAPIListState(state), nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("failed to read tidal api cache: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var state tidalAPIListCache
|
|
||||||
if err := json.Unmarshal(data, &state); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse tidal api cache: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
state.URLs = normalizeTidalAPIURLs(state.URLs)
|
|
||||||
|
|
||||||
tidalAPIListState = cloneTidalAPIListState(&state)
|
|
||||||
return cloneTidalAPIListState(&state), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveTidalAPIListStateLocked(state *tidalAPIListCache) error {
|
|
||||||
appDir, err := EnsureAppDir()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cachePath := filepath.Join(appDir, tidalAPIListCacheFile)
|
|
||||||
payload, err := json.MarshalIndent(state, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to encode tidal api cache: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(cachePath, payload, 0o644); err != nil {
|
|
||||||
return fmt.Errorf("failed to write tidal api cache: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tidalAPIListState = cloneTidalAPIListState(state)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneTidalAPIListState(state *tidalAPIListCache) *tidalAPIListCache {
|
|
||||||
if state == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &tidalAPIListCache{
|
|
||||||
URLs: append([]string(nil), state.URLs...),
|
|
||||||
LastUsedURL: state.LastUsedURL,
|
|
||||||
UpdatedAt: state.UpdatedAt,
|
|
||||||
Source: state.Source,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeTidalAPIURLs(urls []string) []string {
|
|
||||||
seen := make(map[string]struct{}, len(urls))
|
|
||||||
normalized := make([]string, 0, len(urls))
|
|
||||||
|
|
||||||
for _, rawURL := range urls {
|
|
||||||
url := strings.TrimRight(strings.TrimSpace(rawURL), "/")
|
|
||||||
if url == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, exists := seen[url]; exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[url] = struct{}{}
|
|
||||||
normalized = append(normalized, url)
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchTidalAPIURLsFromGist() ([]string, error) {
|
|
||||||
client := &http.Client{Timeout: 12 * time.Second}
|
|
||||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, tidalAPIListGistURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create tidal api gist request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to fetch tidal api gist: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
preview, _ := io.ReadAll(io.LimitReader(resp.Body, 200))
|
|
||||||
return nil, fmt.Errorf("tidal api gist returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(preview)))
|
|
||||||
}
|
|
||||||
|
|
||||||
var urls []string
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&urls); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode tidal api gist: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
urls = normalizeTidalAPIURLs(urls)
|
|
||||||
if len(urls) == 0 {
|
|
||||||
return nil, fmt.Errorf("tidal api gist returned no valid urls")
|
|
||||||
}
|
|
||||||
|
|
||||||
return urls, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func PrimeTidalAPIList() error {
|
|
||||||
_, err := RefreshTidalAPIList(true)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Warning: failed to refresh Tidal API list from gist: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tidalAPIListMu.Lock()
|
|
||||||
defer tidalAPIListMu.Unlock()
|
|
||||||
|
|
||||||
state, loadErr := loadTidalAPIListStateLocked()
|
|
||||||
if loadErr != nil {
|
|
||||||
return loadErr
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(state.URLs) == 0 {
|
|
||||||
return fmt.Errorf("tidal api cache is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
if state.UpdatedAt == 0 {
|
|
||||||
state.UpdatedAt = time.Now().Unix()
|
|
||||||
return saveTidalAPIListStateLocked(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func RefreshTidalAPIList(force bool) ([]string, error) {
|
|
||||||
tidalAPIListMu.Lock()
|
|
||||||
defer tidalAPIListMu.Unlock()
|
|
||||||
|
|
||||||
state, err := loadTidalAPIListStateLocked()
|
|
||||||
if err != nil {
|
|
||||||
state = &tidalAPIListCache{}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !force && len(state.URLs) > 0 {
|
|
||||||
return append([]string(nil), state.URLs...), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
urls, fetchErr := fetchTidalAPIURLsFromGist()
|
|
||||||
if fetchErr != nil {
|
|
||||||
if len(state.URLs) > 0 {
|
|
||||||
return append([]string(nil), state.URLs...), fetchErr
|
|
||||||
}
|
|
||||||
return nil, fetchErr
|
|
||||||
}
|
|
||||||
|
|
||||||
state.URLs = urls
|
|
||||||
state.UpdatedAt = time.Now().Unix()
|
|
||||||
state.Source = "gist"
|
|
||||||
|
|
||||||
if !containsString(state.URLs, state.LastUsedURL) {
|
|
||||||
state.LastUsedURL = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := saveTidalAPIListStateLocked(state); err != nil {
|
|
||||||
return append([]string(nil), state.URLs...), err
|
|
||||||
}
|
|
||||||
|
|
||||||
return append([]string(nil), state.URLs...), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetTidalAPIList() ([]string, error) {
|
|
||||||
tidalAPIListMu.Lock()
|
|
||||||
defer tidalAPIListMu.Unlock()
|
|
||||||
|
|
||||||
state, err := loadTidalAPIListStateLocked()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(state.URLs) == 0 {
|
|
||||||
return nil, fmt.Errorf("no cached tidal api urls")
|
|
||||||
}
|
|
||||||
|
|
||||||
return append([]string(nil), state.URLs...), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetRotatedTidalAPIList() ([]string, error) {
|
|
||||||
tidalAPIListMu.Lock()
|
|
||||||
defer tidalAPIListMu.Unlock()
|
|
||||||
|
|
||||||
state, err := loadTidalAPIListStateLocked()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
urls := state.URLs
|
|
||||||
if len(urls) == 0 {
|
|
||||||
return nil, fmt.Errorf("no cached tidal api urls")
|
|
||||||
}
|
|
||||||
|
|
||||||
return rotateTidalAPIURLs(urls, state.LastUsedURL), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func RememberTidalAPIUsage(apiURL string) error {
|
|
||||||
tidalAPIListMu.Lock()
|
|
||||||
defer tidalAPIListMu.Unlock()
|
|
||||||
|
|
||||||
state, err := loadTidalAPIListStateLocked()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
state.LastUsedURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
|
||||||
if state.UpdatedAt == 0 {
|
|
||||||
state.UpdatedAt = time.Now().Unix()
|
|
||||||
}
|
|
||||||
|
|
||||||
return saveTidalAPIListStateLocked(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
func rotateTidalAPIURLs(urls []string, lastUsedURL string) []string {
|
|
||||||
normalized := normalizeTidalAPIURLs(urls)
|
|
||||||
if len(normalized) < 2 {
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
lastUsedURL = strings.TrimRight(strings.TrimSpace(lastUsedURL), "/")
|
|
||||||
if lastUsedURL == "" {
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
lastIndex := -1
|
|
||||||
for idx, candidate := range normalized {
|
|
||||||
if candidate == lastUsedURL {
|
|
||||||
lastIndex = idx
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if lastIndex == -1 {
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
rotated := make([]string, 0, len(normalized))
|
|
||||||
rotated = append(rotated, normalized[lastIndex+1:]...)
|
|
||||||
rotated = append(rotated, normalized[:lastIndex+1]...)
|
|
||||||
return rotated
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsString(values []string, target string) bool {
|
|
||||||
target = strings.TrimRight(strings.TrimSpace(target), "/")
|
|
||||||
for _, value := range values {
|
|
||||||
if strings.TrimRight(strings.TrimSpace(value), "/") == target {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -24,15 +24,16 @@ import { AudioResamplerPage } from "@/components/AudioResamplerPage";
|
|||||||
import { FileManagerPage } from "@/components/FileManagerPage";
|
import { 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);
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 749.3 227.1" fill="#FFFFFF">
|
||||||
|
<!-- Generator: Adobe Illustrator 29.3.1, SVG Export Plug-In . SVG Version: 2.1.0 Build 151) -->
|
||||||
|
<path d="M222.8,85.4c0-3.4,2.5-5.6,6.4-5.6h18.6c16.9,0,28.3,9.3,28.3,22.9s-11.3,23.3-28.3,23.3h-2.6c-6.5,0-9.8,3.4-9.8,8.8v15.2c0,4.3-2.5,7-6.3,7s-6.3-2.7-6.3-7v-64.6ZM235.4,104.7c0,6.8,3.5,10.1,10.1,10.1h1.6c9.3,0,16.1-3.8,16.1-12.1s-6.8-12.1-16.1-12.1h-1.6c-6.6,0-10.1,3.2-10.1,10.1v4.1ZM276.1,151.1c0,3.6,2.5,5.9,6.3,5.9s4.8-1.6,6.1-5l2.3-6.1c1.8-4.9,5.1-7.1,8.6-7.1h20.5c3.6,0,6.8,2.3,8.6,7.1l2.3,6.1c1.3,3.4,3.6,5,6.1,5,3.8,0,6.3-2.4,6.3-5.9s-.2-2.2-.6-3.4l-24.5-63.8c-1.5-3.9-5-5.8-8.3-5.8s-6.8,1.9-8.3,5.8l-24.5,63.8c-.4,1.2-.6,2.4-.6,3.4ZM300,122.1c0-1.2.3-2.3.9-3.9l4.6-12.9c.9-2.5,2.4-3.7,4.1-3.7s3.2,1.2,4.1,3.7l4.6,12.9c.5,1.6.9,2.7.9,3.9,0,3.2-1.8,5.5-6.7,5.5h-5.8c-4.9,0-6.7-2.3-6.7-5.5ZM339,85.6c0-3.5,2.5-5.8,6.5-5.8h49.7c4,0,6.5,2.4,6.5,5.8s-2.5,5.8-6.5,5.8h-8.3c-6.6,0-10.2,3.4-10.2,11v47.4c0,4.4-2.5,7.1-6.4,7.1s-6.4-2.7-6.4-7.1v-47.4c0-7.7-3.6-11-10.2-11h-8.3c-4,0-6.5-2.4-6.5-5.8ZM413.4,150c0,4.3,2.5,7,6.3,7s6.3-2.7,6.3-7v-17.2c0-4.9,2.8-6.9,6.3-6.9h.9c2.3,0,4.5,1.4,5.9,3.5l16.4,24.1c1.5,2.3,3.5,3.6,5.9,3.6s5.8-2.7,5.8-5.9-.4-2.7-1.4-4.1l-10.9-15.3c-1.3-1.8-1.8-3.4-1.8-4.6,0-2.7,2.4-4.6,5.2-6.7,5.1-3.8,10.6-8.8,10.6-18.3s-10.4-22.3-27.5-22.3h-21.7c-3.9,0-6.3,2.3-6.3,5.6v64.6ZM425.9,103.7v-3.2c0-7,3.7-9.9,9.3-9.9h5.4c9.3,0,15.2,3.5,15.2,11.5s-6.3,11.7-15.6,11.7h-5.1c-5.6,0-9.3-2.9-9.3-9.9ZM484.8,149.8v-64.4c0-3.4,2.4-5.6,6.3-5.6h40.9c3.9,0,6.3,2.3,6.3,5.6s-2.4,5.6-6.3,5.6h-25.8c-5.1,0-8.8,3-8.8,8.8v2.4c0,5.7,3.7,8.8,8.8,8.8h20c3.9,0,6.3,2.3,6.3,5.6s-2.4,5.6-6.3,5.6h-19.2c-5.1,0-9.5,3.1-9.5,9.5v3c0,6.4,4.4,9.5,9.5,9.5h25.1c3.9,0,6.3,2.3,6.3,5.6s-2.4,5.6-6.3,5.6h-40.9c-3.9,0-6.3-2.3-6.3-5.6ZM545.7,117.6c0-23.3,17.5-39.4,38-39.4s38,16.1,38,39.4-17.5,39.4-38,39.4-38-16.1-38-39.4ZM559.9,117.6c0,16.4,9.7,26.9,23.8,26.9s23.8-10.5,23.8-26.9-9.7-26.9-23.8-26.9-23.8,10.4-23.8,26.9ZM636.8,150c0,4.3,2.5,7,6.3,7s6.3-2.7,6.3-7v-33.1c0-4,2.4-5.9,4.9-5.9s3.6,1.1,4.8,3l20.8,34.7c2.8,4.8,5.4,8.3,10.7,8.3s8.8-3.7,8.8-9.6v-62.2c0-4.3-2.5-7-6.3-7s-6.3,2.7-6.3,7v33.1c0,4-2.4,5.9-4.9,5.9s-3.6-1.1-4.8-3l-20.8-34.7c-2.8-4.8-5.4-8.3-10.7-8.3s-8.8,3.7-8.8,9.6v62.2Z"/>
|
||||||
|
<path d="M169.2,87.5c0-16.7-13-30.3-28.2-35.2-18.9-6.1-43.8-5.2-61.9,3.3-21.9,10.3-28.7,32.9-29,55.5-.2,18.5,1.6,67.4,29.2,67.7,20.5.3,23.5-26.1,33-38.8,6.7-9,15.4-11.6,26.1-14.2,18.4-4.5,30.9-19,30.8-38.2Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 27.4.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 1080 1080" style="enable-background:new 0 0 1080 1080;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#FFFFFF;}
|
||||||
|
</style>
|
||||||
|
<path class="st0" d="M1033.05,324.45c-0.19-137.9-107.59-250.92-233.6-291.7c-156.48-50.64-362.86-43.3-512.28,27.2
|
||||||
|
C106.07,145.41,49.18,332.61,47.06,519.31c-1.74,153.5,13.58,557.79,241.62,560.67c169.44,2.15,194.67-216.18,273.07-321.33
|
||||||
|
c55.78-74.81,127.6-95.94,216.01-117.82C929.71,603.22,1033.27,483.3,1033.05,324.45z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 735 B |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,14 +1,14 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { 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. It’s not a paid product, but it’s shared privately through a supporter-only post.
|
This project released as a token of appreciation for those who have supported SpotiFLAC through Ko-fi, Patreon, or crypto. It’s not a paid product, but it’s shared privately through a supporter-only post.
|
||||||
</p>
|
</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>);
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { CircleCheck, Copy } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { openExternal } from "@/lib/utils";
|
||||||
|
import KofiLogo from "@/assets/ko-fi.gif";
|
||||||
|
import KofiSvg from "@/assets/kofi_symbol.svg";
|
||||||
|
import PatreonLogo from "@/assets/patreon.svg";
|
||||||
|
import PatreonSymbol from "@/assets/patreon_symbol.svg";
|
||||||
|
import UsdtBarcode from "@/assets/usdt.jpg";
|
||||||
|
|
||||||
|
export function SupportPage() {
|
||||||
|
const [copiedUsdt, setCopiedUsdt] = useState(false);
|
||||||
|
const [copiedEmail, setCopiedEmail] = useState(false);
|
||||||
|
return (<div className="flex flex-col space-y-3">
|
||||||
|
<div className="flex items-center justify-between shrink-0">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Support Me</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-center p-4">
|
||||||
|
<div className="grid w-full max-w-5xl overflow-hidden rounded-xl border bg-card shadow-sm md:grid-cols-3">
|
||||||
|
<div className="flex min-h-[22rem] flex-col items-center justify-between space-y-6 border-b p-6 md:border-b-0 md:border-r">
|
||||||
|
<div className="flex flex-col items-center space-y-4">
|
||||||
|
<div className="h-32 flex items-center justify-center w-full relative">
|
||||||
|
<img src={KofiLogo} className="w-72 absolute pointer-events-none" alt="Ko-fi"/>
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold text-foreground">Support via Ko-fi</h4>
|
||||||
|
<p className="text-sm text-muted-foreground text-center px-4">
|
||||||
|
Buy me a coffee to help keep development going.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button className="h-10 w-full gap-2 bg-[#72a4f2] text-sm font-semibold text-white hover:bg-[#5f8cd6]" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
||||||
|
<img src={KofiSvg} className="h-6 w-6 shrink-0" alt="" aria-hidden="true"/>
|
||||||
|
Support me on Ko-fi
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-[22rem] flex-col items-center justify-between space-y-6 border-b p-6 md:border-b-0 md:border-r">
|
||||||
|
<div className="flex flex-col items-center space-y-4 w-full">
|
||||||
|
<div className="h-32 flex items-center justify-center w-full px-4">
|
||||||
|
<img src={PatreonLogo} className="w-56 max-w-full brightness-0 dark:brightness-100" alt="Patreon"/>
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold text-foreground">Support via Patreon</h4>
|
||||||
|
<p className="text-sm text-muted-foreground text-center px-4">
|
||||||
|
Join on Patreon to help fund the project and follow updates.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button className="h-10 w-full gap-2 bg-[#ff424d] text-sm font-semibold text-white hover:bg-[#e63945]" onClick={() => openExternal("https://www.patreon.com/cw/afkarxyz")}>
|
||||||
|
<img src={PatreonSymbol} className="h-5 w-5 shrink-0" alt="" aria-hidden="true"/>
|
||||||
|
Support me on Patreon
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-[22rem] flex-col items-center justify-between space-y-6 p-6">
|
||||||
|
<div className="flex flex-col items-center space-y-4 w-full">
|
||||||
|
<div className="h-32 flex items-center justify-center">
|
||||||
|
<div className="rounded-xl border bg-white p-2 shadow-sm">
|
||||||
|
<img src={UsdtBarcode} className="h-24 w-24 object-contain" alt="USDT Barcode"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold text-foreground">USDT (TRC20)</h4>
|
||||||
|
<p className="text-sm text-muted-foreground text-center px-4">
|
||||||
|
Prefer crypto? Use the QR code or wallet address below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-10 w-full items-center justify-between gap-2 rounded-lg border bg-muted/50 py-1.5 pl-3 pr-1.5">
|
||||||
|
<code className="truncate text-xs font-mono text-muted-foreground" title="THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs">
|
||||||
|
THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs
|
||||||
|
</code>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 hover:bg-background" onClick={() => {
|
||||||
|
navigator.clipboard.writeText("THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs");
|
||||||
|
setCopiedUsdt(true);
|
||||||
|
setTimeout(() => setCopiedUsdt(false), 500);
|
||||||
|
}}>
|
||||||
|
{copiedUsdt ? <CircleCheck className="h-3.5 w-3.5 text-green-500"/> : <Copy className="h-3.5 w-3.5"/>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 w-full max-w-5xl rounded-xl border bg-muted/30 px-4 py-3 text-center text-sm text-muted-foreground">
|
||||||
|
If you have any questions or need help with donating, feel free to reach out via{" "}
|
||||||
|
<button type="button" className="font-medium text-foreground underline-offset-4 hover:underline" onClick={() => openExternal("https://t.me/afkarxyz")}>
|
||||||
|
Telegram
|
||||||
|
</button>{" "}
|
||||||
|
or{" "}
|
||||||
|
<button type="button" className="font-medium text-foreground underline-offset-4 hover:underline" onClick={() => {
|
||||||
|
navigator.clipboard.writeText("hi@afkarxyz.fyi");
|
||||||
|
setCopiedEmail(true);
|
||||||
|
setTimeout(() => setCopiedEmail(false), 500);
|
||||||
|
}}>
|
||||||
|
{copiedEmail ? "copied" : "hi@afkarxyz.fyi"}
|
||||||
|
</button>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
@@ -176,7 +176,7 @@ export function TitleBar() {
|
|||||||
</div>)}
|
</div>)}
|
||||||
</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 };
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import type { Variants } from "motion/react";
|
|
||||||
import { motion, useAnimation } from "motion/react";
|
|
||||||
import type { HTMLAttributes } from "react";
|
|
||||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
export interface GithubIconHandle {
|
|
||||||
startAnimation: () => void;
|
|
||||||
stopAnimation: () => void;
|
|
||||||
}
|
|
||||||
interface GithubIconProps extends HTMLAttributes<HTMLDivElement> {
|
|
||||||
size?: number;
|
|
||||||
}
|
|
||||||
const BODY_VARIANTS: Variants = {
|
|
||||||
normal: {
|
|
||||||
opacity: 1,
|
|
||||||
pathLength: 1,
|
|
||||||
scale: 1,
|
|
||||||
transition: {
|
|
||||||
duration: 0.3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
animate: {
|
|
||||||
opacity: [0, 1],
|
|
||||||
pathLength: [0, 1],
|
|
||||||
scale: [0.9, 1],
|
|
||||||
transition: {
|
|
||||||
duration: 0.4,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const TAIL_VARIANTS: Variants = {
|
|
||||||
normal: {
|
|
||||||
pathLength: 1,
|
|
||||||
rotate: 0,
|
|
||||||
transition: {
|
|
||||||
duration: 0.3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
draw: {
|
|
||||||
pathLength: [0, 1],
|
|
||||||
rotate: 0,
|
|
||||||
transition: {
|
|
||||||
duration: 0.5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
wag: {
|
|
||||||
pathLength: 1,
|
|
||||||
rotate: [0, -15, 15, -10, 10, -5, 5],
|
|
||||||
transition: {
|
|
||||||
duration: 2.5,
|
|
||||||
ease: "easeInOut",
|
|
||||||
repeat: Number.POSITIVE_INFINITY,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const GithubIcon = forwardRef<GithubIconHandle, GithubIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
|
||||||
const bodyControls = useAnimation();
|
|
||||||
const tailControls = useAnimation();
|
|
||||||
const isControlledRef = useRef(false);
|
|
||||||
useImperativeHandle(ref, () => {
|
|
||||||
isControlledRef.current = true;
|
|
||||||
return {
|
|
||||||
startAnimation: async () => {
|
|
||||||
bodyControls.start("animate");
|
|
||||||
await tailControls.start("draw");
|
|
||||||
tailControls.start("wag");
|
|
||||||
},
|
|
||||||
stopAnimation: () => {
|
|
||||||
bodyControls.start("normal");
|
|
||||||
tailControls.start("normal");
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const handleMouseEnter = useCallback(async (e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (isControlledRef.current) {
|
|
||||||
onMouseEnter?.(e);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
bodyControls.start("animate");
|
|
||||||
await tailControls.start("draw");
|
|
||||||
tailControls.start("wag");
|
|
||||||
}
|
|
||||||
}, [bodyControls, onMouseEnter, tailControls]);
|
|
||||||
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (isControlledRef.current) {
|
|
||||||
onMouseLeave?.(e);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
bodyControls.start("normal");
|
|
||||||
tailControls.start("normal");
|
|
||||||
}
|
|
||||||
}, [bodyControls, tailControls, onMouseLeave]);
|
|
||||||
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
|
||||||
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<motion.path animate={bodyControls} d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" initial="normal" variants={BODY_VARIANTS}/>
|
|
||||||
<motion.path animate={tailControls} d="M9 18c-4.51 2-5-2-7-2" initial="normal" variants={TAIL_VARIANTS}/>
|
|
||||||
</svg>
|
|
||||||
</div>);
|
|
||||||
});
|
|
||||||
GithubIcon.displayName = "GithubIcon";
|
|
||||||
export { GithubIcon };
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
'use client';
|
||||||
|
import type { Variants } from 'motion/react';
|
||||||
|
import type { HTMLAttributes } from 'react';
|
||||||
|
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||||
|
import { motion, useAnimation } from 'motion/react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface ToolCaseIconHandle {
|
||||||
|
startAnimation: () => void;
|
||||||
|
stopAnimation: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolCaseIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DRAW_VARIANTS: Variants = {
|
||||||
|
normal: {
|
||||||
|
pathLength: 1,
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
pathLength: [0, 1],
|
||||||
|
opacity: [0, 1],
|
||||||
|
transition: {
|
||||||
|
duration: 0.6,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const HANDLE_VARIANTS: Variants = {
|
||||||
|
normal: {
|
||||||
|
scaleX: 1,
|
||||||
|
originX: '50%',
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
scaleX: [0.6, 1.1, 1],
|
||||||
|
originX: '50%',
|
||||||
|
transition: {
|
||||||
|
duration: 0.45,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ToolCaseIcon = forwardRef<ToolCaseIconHandle, ToolCaseIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||||
|
const controls = useAnimation();
|
||||||
|
const isControlledRef = useRef(false);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => {
|
||||||
|
isControlledRef.current = true;
|
||||||
|
return {
|
||||||
|
startAnimation: () => controls.start('animate'),
|
||||||
|
stopAnimation: () => controls.start('normal'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!isControlledRef.current) {
|
||||||
|
controls.start('animate');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
onMouseEnter?.(e);
|
||||||
|
}
|
||||||
|
}, [controls, onMouseEnter]);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!isControlledRef.current) {
|
||||||
|
controls.start('normal');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
onMouseLeave?.(e);
|
||||||
|
}
|
||||||
|
}, [controls, onMouseLeave]);
|
||||||
|
|
||||||
|
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<motion.path d="M10 15h4" variants={HANDLE_VARIANTS} animate={controls} initial="normal"/>
|
||||||
|
<motion.path d="m14.817 10.995-.971-1.45 1.034-1.232a2 2 0 0 0-2.025-3.238l-1.82.364L9.91 3.885a2 2 0 0 0-3.625.748L6.141 6.55l-1.725.426a2 2 0 0 0-.19 3.756l.657.27" variants={DRAW_VARIANTS} animate={controls} initial="normal"/>
|
||||||
|
<motion.path d="m18.822 10.995 2.26-5.38a1 1 0 0 0-.557-1.318L16.954 2.9a1 1 0 0 0-1.281.533l-.924 2.122" variants={DRAW_VARIANTS} animate={controls} initial="normal"/>
|
||||||
|
<motion.path d="M4 12.006A1 1 0 0 1 4.994 11H19a1 1 0 0 1 1 1v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" variants={DRAW_VARIANTS} animate={controls} initial="normal"/>
|
||||||
|
</svg>
|
||||||
|
</div>);
|
||||||
|
});
|
||||||
|
|
||||||
|
ToolCaseIcon.displayName = 'ToolCaseIcon';
|
||||||
|
|
||||||
|
export { ToolCaseIcon };
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { 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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user