Compare commits

..

9 Commits

Author SHA1 Message Date
429Enjoyer 0c3a7b70af v7.1.7 2026-05-20 05:56:51 +07:00
429Enjoyer 254022d81d .clean up the code 2026-05-20 05:56:16 +07:00
429Enjoyer b3ebef5ab9 v7.1.1 2026-05-20 05:53:45 +07:00
afkarxyz 0093df6016 v7.1.6 2026-04-26 07:33:40 +07:00
afkarxyz 30cbcf8ab1 v7.1.5 2026-04-19 23:16:15 +07:00
afkarxyz 7346730be9 v7.1.4 2026-04-14 07:36:41 +07:00
afkarxyz 59a057b14a Update README 2026-04-03 12:46:16 +07:00
afkarxyz 2bc2c0bf03 v7.1.3 2026-04-02 18:42:35 +07:00
afkarxyz f13359df7f v7.1.2 2026-03-25 21:06:45 +07:00
51 changed files with 4128 additions and 2322 deletions
+2 -1
View File
@@ -1 +1,2 @@
ko_fi: afkarxyz
ko_fi: afkarxyz
patreon: afkarxyz
+3 -5
View File
@@ -24,14 +24,12 @@ Get Spotify tracks in true Lossless from Tidal, Qobuz, Amazon Music, Deezer & Ap
Download Spotify Tracks, Albums, Playlists & Discography as MP3/OGG/Opus.
## Related projects
### [SpotiFLAC (Mobile)](https://github.com/zarzet/SpotiFLAC-Mobile)
SpotiFLAC for Android & iOS — maintained by [@zarzet](https://github.com/zarzet)
### [SpotiFLAC (CLI)](https://github.com/Nizarberyan/SpotiFLAC)
SpotiFLAC for command-line environments — maintained by [@Nizarberyan](https://github.com/Nizarberyan)
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
SpotiFLAC Python library for SpotiFLAC integration — maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu)
@@ -108,7 +106,7 @@ The software is provided "as is", without warranty of any kind. The author assum
## API Credits
[MusicBrainz](https://musicbrainz.org) · [LRCLIB](https://lrclib.net) · [Songlink/Odesli](https://song.link) · [hifi-api](https://github.com/binimum/hifi-api) · [dabmusic.xyz](https://dabmusic.xyz)
[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]
>
+548 -136
View File
@@ -33,12 +33,41 @@ type CurrentIPInfo struct {
Source string `json:"source,omitempty"`
}
type APIStatusTargetResult struct {
Target string `json:"target"`
Label string `json:"label"`
Online bool `json:"online"`
Message string `json:"message,omitempty"`
}
type APIStatusReport struct {
Type string `json:"type"`
Online bool `json:"online"`
RequireAll bool `json:"require_all"`
Details []APIStatusTargetResult `json:"details"`
}
const checkOperationTimeout = 10 * time.Second
func NewApp() *App {
return &App{}
}
func (a *App) LogStatusConsole(level string, message string) {
normalizedLevel := strings.ToLower(strings.TrimSpace(level))
if normalizedLevel == "" {
normalizedLevel = "info"
}
line := fmt.Sprintf("[%s] [%s] %s\n", time.Now().Format("15:04:05"), normalizedLevel, strings.TrimSpace(message))
switch normalizedLevel {
case "error":
_, _ = fmt.Fprint(os.Stderr, line)
default:
fmt.Print(line)
}
}
type timedResult[T any] struct {
value T
err error
@@ -276,11 +305,12 @@ func (a *App) startup(ctx context.Context) {
if err := backend.InitProviderPriorityDB(); err != nil {
fmt.Printf("Failed to init provider priority DB: %v\n", err)
}
go func() {
if err := backend.PrimeTidalAPIList(); err != nil {
fmt.Printf("Failed to prime Tidal API list: %v\n", err)
}
}()
if err := backend.CleanupLegacyTidalPublicAPIState(); err != nil {
fmt.Printf("Failed to clean legacy Tidal API cache: %v\n", err)
}
if err := backend.SanitizePersistedConfigSettings(); err != nil {
fmt.Printf("Failed to sanitize persisted config settings: %v\n", err)
}
}
func (a *App) shutdown(ctx context.Context) {
@@ -307,7 +337,6 @@ type DownloadRequest struct {
ReleaseDate string `json:"release_date,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
TidalAPIURL string `json:"tidal_api_url,omitempty"`
TidalVariant string `json:"tidal_variant,omitempty"`
OutputDir string `json:"output_dir,omitempty"`
AudioFormat string `json:"audio_format,omitempty"`
FilenameFormat string `json:"filename_format,omitempty"`
@@ -508,7 +537,8 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
if req.FilenameFormat == "" {
req.FilenameFormat = "title-artist"
}
if req.ISRC == "" && strings.Contains(req.FilenameFormat, "{isrc}") && req.SpotifyID != "" {
shouldResolveISRC := strings.Contains(req.FilenameFormat, "{isrc}") || backend.GetExistingFileCheckModeSetting() == "isrc"
if req.ISRC == "" && shouldResolveISRC && req.SpotifyID != "" {
req.ISRC = backend.ResolveTrackISRC(req.SpotifyID)
}
@@ -662,24 +692,15 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
}
case "tidal":
tidalVariant := strings.ToLower(strings.TrimSpace(req.TidalVariant))
if tidalVariant == "alt" {
downloader := backend.NewTidalDownloader("")
filename, err = downloader.DownloadAlt(req.SpotifyID, req.OutputDir, 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.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
} else if req.TidalAPIURL == "" || req.TidalAPIURL == "auto" {
downloader := backend.NewTidalDownloader("")
if req.ServiceURL != "" {
filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
} else {
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
}
if !strings.HasPrefix(strings.TrimRight(strings.TrimSpace(req.TidalAPIURL), "/"), "https://") {
err = fmt.Errorf("a configured HTTPS Tidal instance is required")
break
}
downloader := backend.NewTidalDownloader(req.TidalAPIURL)
if req.ServiceURL != "" {
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
} else {
downloader := backend.NewTidalDownloader(req.TidalAPIURL)
if req.ServiceURL != "" {
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
} else {
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
}
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
}
case "qobuz":
@@ -795,9 +816,6 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
}
historySource := req.Service
if req.Service == "tidal" && strings.EqualFold(strings.TrimSpace(req.TidalVariant), "alt") {
historySource = "tidal alt"
}
go func(fPath, track, artist, album, sID, cover, format, source string) {
time.Sleep(2 * time.Second)
@@ -826,21 +844,21 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
DurationStr: durationStr,
CoverURL: cover,
Quality: quality,
Format: strings.ToUpper(format),
Path: fPath,
Source: source,
}
if item.Format == "" || item.Format == "LOSSLESS" {
ext := filepath.Ext(fPath)
if len(ext) > 1 {
item.Format = strings.ToUpper(ext[1:])
}
item.Format = strings.ToUpper(strings.TrimSpace(format))
if ext := filepath.Ext(fPath); len(ext) > 1 {
item.Format = strings.ToUpper(ext[1:])
}
switch item.Format {
case "6", "7", "27":
case "6", "7", "27", "LOSSLESS", "HI_RES", "HI_RES_LOSSLESS":
item.Format = "FLAC"
case "ALAC", "APPLE", "ATMOS", "M4A-AAC", "M4A-ALAC":
item.Format = "M4A"
}
backend.AddHistoryItem(item, "SpotiFLAC")
@@ -993,15 +1011,7 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
isOnline, err := runWithTimeout(checkOperationTimeout, func() (bool, error) {
switch apiType {
case "tidal":
if checkGroupedAPIStatus("tidal", buildTidalStatusCheckURLs(apiURL)) {
return true, nil
}
if strings.TrimSpace(apiURL) == "" {
if _, refreshErr := backend.RefreshTidalAPIList(true); refreshErr == nil && checkGroupedAPIStatus("tidal", buildTidalStatusCheckURLs("")) {
return true, nil
}
}
return false, nil
return checkGroupedAPIStatus("tidal", buildTidalStatusCheckURLs(apiURL)), nil
case "qobuz", "qbz":
return checkGroupedAPIStatus("qobuz", buildQobuzStatusCheckURLs(apiURL)), nil
case "amazon":
@@ -1029,48 +1039,137 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
return isOnline
}
func (a *App) CheckAPIStatusReport(apiType string, apiURL string) APIStatusReport {
report, err := runWithTimeout(checkOperationTimeout, func() (APIStatusReport, error) {
switch apiType {
case "tidal":
return buildGroupedAPIStatusReport("tidal", buildTidalStatusCheckURLs(apiURL), false), nil
case "qobuz", "qbz":
return buildGroupedAPIStatusReport("qobuz", buildQobuzStatusCheckURLs(apiURL), false), nil
case "amazon":
return buildGroupedAPIStatusReport("amazon", buildAmazonStatusCheckURLs(apiURL), false), nil
case "lrclib":
return buildGroupedAPIStatusReport("lrclib", buildLRCLIBStatusCheckURLs(apiURL), false), nil
case "musicbrainz":
return buildGroupedAPIStatusReport("musicbrainz", buildMusicBrainzStatusCheckURLs(apiURL), false), nil
default:
return buildGroupedAPIStatusReport(apiType, []string{strings.TrimSpace(apiURL)}, false), nil
}
})
if err != nil {
return APIStatusReport{
Type: apiType,
Online: false,
RequireAll: apiType == "qobuz" || apiType == "qbz",
Details: []APIStatusTargetResult{{
Target: strings.TrimSpace(apiURL),
Label: describeAPIStatusTarget(apiType, apiURL),
Online: false,
Message: err.Error(),
}},
}
}
return report
}
func (a *App) CheckCustomTidalAPI(apiURL string) bool {
type tidalProbeResponse struct {
Version string `json:"version"`
Data struct {
TrackID int64 `json:"trackId"`
AssetPresentation string `json:"assetPresentation"`
ManifestMimeType string `json:"manifestMimeType"`
Manifest string `json:"manifest"`
} `json:"data"`
}
type tidalLegacyResponse struct {
OriginalTrackURL string `json:"OriginalTrackUrl"`
}
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
if apiURL == "" {
return false
}
const probeTrackID int64 = 441821360
probeURL := fmt.Sprintf("%s/track/?id=%d&quality=LOSSLESS", apiURL, probeTrackID)
req, err := http.NewRequest(http.MethodGet, probeURL, nil)
if err != nil {
fmt.Printf("[CheckCustomTidalAPI] Failed to create request for %s: %v\n", apiURL, err)
return false
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36")
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 12 * time.Second}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("[CheckCustomTidalAPI] Probe request failed for %s: %v\n", apiURL, err)
return false
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
fmt.Printf("[CheckCustomTidalAPI] Failed to read probe response for %s: %v\n", apiURL, err)
return false
}
if resp.StatusCode != http.StatusOK {
fmt.Printf("[CheckCustomTidalAPI] Probe returned status %d for %s: %s\n", resp.StatusCode, apiURL, previewResponseBody(body, 200))
return false
}
var probe tidalProbeResponse
if err := json.Unmarshal(body, &probe); err == nil {
assetPresentation := strings.ToUpper(strings.TrimSpace(probe.Data.AssetPresentation))
switch assetPresentation {
case "FULL":
if strings.TrimSpace(probe.Data.Manifest) != "" {
fmt.Printf("[CheckCustomTidalAPI] Tidal API is ONLINE for %s (assetPresentation=%s)\n", apiURL, assetPresentation)
return true
}
fmt.Printf("[CheckCustomTidalAPI] Probe returned FULL without manifest for %s\n", apiURL)
return false
case "PREVIEW":
fmt.Printf("[CheckCustomTidalAPI] Probe returned PREVIEW for %s\n", apiURL)
return false
case "":
default:
fmt.Printf("[CheckCustomTidalAPI] Probe returned unsupported assetPresentation=%s for %s\n", assetPresentation, apiURL)
return false
}
}
var legacy []tidalLegacyResponse
if err := json.Unmarshal(body, &legacy); err == nil {
for _, item := range legacy {
if strings.TrimSpace(item.OriginalTrackURL) != "" {
fmt.Printf("[CheckCustomTidalAPI] Tidal API is ONLINE for %s (legacy response)\n", apiURL)
return true
}
}
}
fmt.Printf("[CheckCustomTidalAPI] Probe response was unusable for %s: %s\n", apiURL, previewResponseBody(body, 200))
return false
}
func buildTidalStatusCheckURLs(apiURL string) []string {
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
if apiURL != "" {
return []string{fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL)}
if apiURL == "" {
return nil
}
apis, err := backend.GetRotatedTidalAPIList()
if err != nil {
fmt.Printf("Warning: failed to load rotated Tidal API list for status check: %v\n", err)
}
urls := make([]string, 0, len(apis))
for _, baseURL := range apis {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
continue
}
urls = append(urls, fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", baseURL))
}
return urls
return []string{fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL)}
}
func buildQobuzStatusCheckURLs(apiURL string) []string {
if trimmed := strings.TrimSpace(apiURL); trimmed != "" {
return []string{buildQobuzStatusCheckURL(trimmed)}
return []string{trimmed}
}
bases := backend.GetQobuzStreamAPIBaseURLs()
urls := make([]string, 0, len(bases))
for _, baseURL := range bases {
urls = append(urls, buildQobuzStatusCheckURL(baseURL))
}
return urls
}
func buildQobuzStatusCheckURL(apiBase string) string {
apiBase = strings.TrimSpace(apiBase)
if strings.Contains(apiBase, "qobuz.spotbye.qzz.io") {
return fmt.Sprintf("%s360735657?quality=27", apiBase)
}
return fmt.Sprintf("%s360735657&quality=27", apiBase)
return backend.GetQobuzDownloadProviderURLs()
}
func buildAmazonStatusCheckURLs(apiURL string) []string {
@@ -1136,8 +1235,224 @@ func checkGroupedAPIStatus(apiType string, checkURLs []string) bool {
return false
}
func buildGroupedAPIStatusReport(apiType string, checkURLs []string, requireAll bool) APIStatusReport {
filtered := make([]string, 0, len(checkURLs))
for _, rawURL := range checkURLs {
target := strings.TrimSpace(rawURL)
if target == "" {
continue
}
filtered = append(filtered, target)
}
report := APIStatusReport{
Type: apiType,
Online: !requireAll,
RequireAll: requireAll,
Details: make([]APIStatusTargetResult, len(filtered)),
}
if len(filtered) == 0 {
report.Online = false
return report
}
var wg sync.WaitGroup
for index, target := range filtered {
wg.Add(1)
go func(idx int, rawTarget string) {
defer wg.Done()
report.Details[idx] = checkSingleAPIStatusDetailed(apiType, rawTarget)
}(index, target)
}
wg.Wait()
if requireAll {
report.Online = true
for _, detail := range report.Details {
if !detail.Online {
report.Online = false
break
}
}
} else {
report.Online = false
for _, detail := range report.Details {
if detail.Online {
report.Online = true
break
}
}
}
return report
}
func checkAllGroupedAPIStatus(apiType string, checkURLs []string) bool {
filtered := make([]string, 0, len(checkURLs))
for _, rawURL := range checkURLs {
url := strings.TrimSpace(rawURL)
if url == "" {
continue
}
filtered = append(filtered, url)
}
if len(filtered) == 0 {
return false
}
results := make(chan bool, len(filtered))
var wg sync.WaitGroup
for _, checkURL := range filtered {
wg.Add(1)
go func(target string) {
defer wg.Done()
results <- checkSingleAPIStatus(apiType, target)
}(checkURL)
}
go func() {
wg.Wait()
close(results)
}()
for online := range results {
if !online {
return false
}
}
return true
}
func describeAPIStatusTarget(apiType string, checkURL string) string {
trimmedType := strings.TrimSpace(strings.ToLower(apiType))
trimmedURL := strings.TrimSpace(checkURL)
if trimmedType == "qobuz" || trimmedType == "qbz" {
switch {
case backend.IsQobuzWJHEProviderURL(trimmedURL):
return "WJHE"
case backend.IsQobuzMusicDLProviderURL(trimmedURL):
return "MusicDL"
case backend.IsQobuzGDStudioProviderURL(trimmedURL):
parsed, err := url.Parse(trimmedURL)
if err == nil {
host := strings.ToLower(strings.TrimSpace(parsed.Host))
switch {
case strings.Contains(host, "xyz"):
return "GDStudio XYZ"
case strings.Contains(host, "org"):
return "GDStudio ORG"
}
}
return "GDStudio"
}
}
if trimmedURL != "" {
if parsed, err := url.Parse(trimmedURL); err == nil && strings.TrimSpace(parsed.Host) != "" {
return strings.TrimSpace(parsed.Host)
}
}
if trimmedType == "" {
return "Unknown"
}
return strings.ToUpper(trimmedType)
}
func checkSingleAPIStatusDetailed(apiType string, checkURL string) APIStatusTargetResult {
result := APIStatusTargetResult{
Target: strings.TrimSpace(checkURL),
Label: describeAPIStatusTarget(apiType, checkURL),
}
client := &http.Client{Timeout: 4 * time.Second}
trimmedType := strings.TrimSpace(strings.ToLower(apiType))
if trimmedType == "qobuz" || trimmedType == "qbz" {
var err error
switch {
case backend.IsQobuzWJHEProviderURL(checkURL):
err = backend.CheckQobuzWJHEStatusDetailed(client)
case backend.IsQobuzMusicDLProviderURL(checkURL):
err = backend.CheckQobuzMusicDLStatusDetailed(client)
case backend.IsQobuzGDStudioProviderURL(checkURL):
err = backend.CheckQobuzGDStudioAPIStatusDetailed(client, checkURL)
default:
err = fmt.Errorf("unknown qobuz provider url: %s", strings.TrimSpace(checkURL))
}
if err != nil {
result.Message = err.Error()
return result
}
result.Online = true
result.Message = "stream URL resolved"
return result
}
req, err := backend.NewRequestWithDefaultHeaders(http.MethodGet, checkURL, nil)
if err != nil {
result.Message = fmt.Sprintf("failed to create request: %v", err)
return result
}
resp, err := client.Do(req)
if err != nil {
result.Message = fmt.Sprintf("request failed: %v", err)
return result
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 2048))
if err != nil {
result.Message = fmt.Sprintf("failed to read response: %v", err)
return result
}
switch trimmedType {
case "amazon":
if resp.StatusCode == http.StatusOK && strings.Contains(string(body), `"amazonMusic":"up"`) {
result.Online = true
result.Message = `amazonMusic="up"`
return result
}
if resp.StatusCode != http.StatusOK {
result.Message = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, previewResponseBody(body, 160))
return result
}
result.Message = `amazonMusic was not reported as "up"`
return result
default:
if resp.StatusCode == http.StatusOK {
result.Online = true
result.Message = fmt.Sprintf("HTTP %d", resp.StatusCode)
return result
}
result.Message = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, previewResponseBody(body, 160))
return result
}
}
func checkSingleAPIStatus(apiType string, checkURL string) bool {
client := &http.Client{Timeout: 4 * time.Second}
if apiType == "qobuz" || apiType == "qbz" {
switch {
case backend.IsQobuzWJHEProviderURL(checkURL):
return backend.CheckQobuzWJHEStatus(client)
case backend.IsQobuzMusicDLProviderURL(checkURL):
return backend.CheckQobuzMusicDLStatus(client)
case backend.IsQobuzGDStudioProviderURL(checkURL):
return backend.CheckQobuzGDStudioAPIStatus(client, checkURL)
}
}
req, err := backend.NewRequestWithDefaultHeaders(http.MethodGet, checkURL, nil)
if err != nil {
return false
@@ -1733,6 +2048,68 @@ type CheckFileExistenceResult struct {
ArtistName string `json:"artist_name,omitempty"`
}
type existingFileLookupIndex struct {
byFilename map[string]string
byISRC map[string]string
}
func isAudioFileForExistenceCheck(path string) bool {
switch strings.ToLower(filepath.Ext(path)) {
case ".flac", ".mp3", ".m4a":
return true
default:
return false
}
}
func normalizeExistingFileIdentifier(value string) string {
return strings.ToUpper(strings.TrimSpace(value))
}
func buildExistingFileLookupIndex(scanRoot string, mode string) existingFileLookupIndex {
index := existingFileLookupIndex{
byFilename: make(map[string]string),
byISRC: make(map[string]string),
}
scanRoot = backend.NormalizePath(scanRoot)
if scanRoot == "" {
return index
}
_ = filepath.Walk(scanRoot, func(path string, info os.FileInfo, err error) error {
if err != nil || info == nil || info.IsDir() || !isAudioFileForExistenceCheck(path) {
return nil
}
if info.Size() <= 100*1024 {
return nil
}
if _, exists := index.byFilename[info.Name()]; !exists {
index.byFilename[info.Name()] = path
}
if mode == "filename" {
return nil
}
metadata, metadataErr := backend.ExtractFullMetadataFromFile(path)
if metadataErr != nil {
return nil
}
if normalizedISRC := normalizeExistingFileIdentifier(metadata.ISRC); normalizedISRC != "" {
if _, exists := index.byISRC[normalizedISRC]; !exists {
index.byISRC[normalizedISRC] = path
}
}
return nil
})
return index
}
func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []CheckFileExistenceRequest) []CheckFileExistenceResult {
if len(tracks) == 0 {
return []CheckFileExistenceResult{}
@@ -1745,6 +2122,11 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
defaultFilenameFormat := "title-artist"
redownloadWithSuffix := backend.GetRedownloadWithSuffixSetting()
existingFileCheckMode := backend.GetExistingFileCheckModeSetting()
scanRoot := outputDir
if rootDir != "" {
scanRoot = rootDir
}
type result struct {
index int
@@ -1752,29 +2134,13 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
}
resultsChan := make(chan result, len(tracks))
var rootDirFiles map[string]string
rootDirFilesOnce := false
getRootDirFiles := func() map[string]string {
if rootDirFilesOnce {
return rootDirFiles
}
rootDirFiles = make(map[string]string)
if rootDir != "" && rootDir != outputDir {
filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if !info.IsDir() {
if strings.EqualFold(filepath.Ext(path), ".flac") || strings.EqualFold(filepath.Ext(path), ".mp3") {
rootDirFiles[info.Name()] = path
}
}
return nil
})
}
rootDirFilesOnce = true
return rootDirFiles
var lookupIndex existingFileLookupIndex
var lookupIndexOnce sync.Once
getLookupIndex := func() existingFileLookupIndex {
lookupIndexOnce.Do(func() {
lookupIndex = buildExistingFileLookupIndex(scanRoot, existingFileCheckMode)
})
return lookupIndex
}
for i, track := range tracks {
@@ -1796,7 +2162,8 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
filenameFormat = defaultFilenameFormat
}
isrc := strings.TrimSpace(t.ISRC)
if isrc == "" && strings.Contains(filenameFormat, "{isrc}") && t.SpotifyID != "" {
shouldResolveISRC := existingFileCheckMode == "isrc" || strings.Contains(filenameFormat, "{isrc}")
if isrc == "" && shouldResolveISRC && t.SpotifyID != "" {
isrc = backend.ResolveTrackISRC(t.SpotifyID)
}
@@ -1806,8 +2173,11 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
}
fileExt := ".flac"
if t.AudioFormat == "mp3" {
switch strings.ToLower(strings.TrimSpace(t.AudioFormat)) {
case "mp3":
fileExt = ".mp3"
case "m4a", "m4a-aac", "m4a-alac", "alac", "atmos", "apple":
fileExt = ".m4a"
}
expectedFilenameBase := backend.BuildExpectedFilename(
@@ -1836,14 +2206,29 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
expectedPath := filepath.Join(targetDir, expectedFilename)
if redownloadWithSuffix {
expectedPath, _ = backend.ResolveOutputPathForDownload(expectedPath, true)
res.FilePath = filepath.Base(expectedPath)
} else {
resultsChan <- result{index: idx, result: res}
return
}
normalizedISRC := normalizeExistingFileIdentifier(isrc)
effectiveMode := existingFileCheckMode
if effectiveMode == "isrc" && normalizedISRC == "" {
effectiveMode = "filename"
}
switch effectiveMode {
case "isrc":
if path, ok := getLookupIndex().byISRC[normalizedISRC]; ok {
res.Exists = true
res.FilePath = path
}
default:
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
res.Exists = true
res.FilePath = expectedPath
} else {
res.FilePath = expectedFilename
} else if path, ok := getLookupIndex().byFilename[filepath.Base(expectedPath)]; ok {
res.Exists = true
res.FilePath = path
}
}
@@ -1852,39 +2237,10 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
}
results := make([]CheckFileExistenceResult, len(tracks))
missingIndices := []int{}
for i := 0; i < len(tracks); i++ {
r := <-resultsChan
results[r.index] = r.result
if !results[r.index].Exists {
missingIndices = append(missingIndices, r.index)
}
}
if len(missingIndices) > 0 && rootDir != "" {
filesMap := getRootDirFiles()
if len(filesMap) > 0 {
for _, idx := range missingIndices {
expectedFilename := results[idx].FilePath
baseName := filepath.Base(expectedFilename)
if path, ok := filesMap[baseName]; ok {
results[idx].Exists = true
results[idx].FilePath = path
} else {
results[idx].FilePath = ""
}
}
} else {
for _, idx := range missingIndices {
results[idx].FilePath = ""
}
}
} else {
for _, idx := range missingIndices {
results[idx].FilePath = ""
}
}
return results
@@ -1910,11 +2266,20 @@ func (a *App) GetConfigPath() (string, error) {
return filepath.Join(dir, "config.json"), nil
}
func (a *App) GetFontsPath() (string, error) {
dir, err := backend.GetFFmpegDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "fonts.json"), nil
}
func (a *App) SaveSettings(settings map[string]interface{}) error {
configPath, err := a.GetConfigPath()
if err != nil {
return err
}
settings = backend.SanitizeSettingsMap(settings)
dir := filepath.Dir(configPath)
if _, err := os.Stat(dir); os.IsNotExist(err) {
@@ -1931,6 +2296,27 @@ func (a *App) SaveSettings(settings map[string]interface{}) error {
return os.WriteFile(configPath, data, 0644)
}
func (a *App) SaveFonts(fonts []map[string]interface{}) error {
fontsPath, err := a.GetFontsPath()
if err != nil {
return err
}
dir := filepath.Dir(fontsPath)
if _, err := os.Stat(dir); os.IsNotExist(err) {
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
}
data, err := json.MarshalIndent(fonts, "", " ")
if err != nil {
return err
}
return os.WriteFile(fontsPath, data, 0644)
}
func (a *App) LoadSettings() (map[string]interface{}, error) {
configPath, err := a.GetConfigPath()
if err != nil {
@@ -1951,7 +2337,33 @@ func (a *App) LoadSettings() (map[string]interface{}, error) {
return nil, err
}
return settings, nil
return backend.SanitizeSettingsMap(settings), nil
}
func (a *App) LoadFonts() ([]map[string]interface{}, error) {
fontsPath, err := a.GetFontsPath()
if err != nil {
return nil, err
}
if _, err := os.Stat(fontsPath); os.IsNotExist(err) {
return nil, nil
}
data, err := os.ReadFile(fontsPath)
if err != nil {
return nil, err
}
var fonts []map[string]interface{}
if err := json.Unmarshal(data, &fonts); err != nil {
return nil, err
}
if fonts == nil {
return []map[string]interface{}{}, nil
}
return fonts, nil
}
func (a *App) CheckFFmpegInstalled() (bool, error) {
+80
View File
@@ -1,6 +1,9 @@
package backend
import (
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
@@ -10,6 +13,7 @@ import (
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
@@ -23,6 +27,76 @@ type AmazonStreamResponse struct {
DecryptionKey string `json:"decryptionKey"`
}
var (
amazonMusicDebugKeyOnce sync.Once
amazonMusicDebugKey string
amazonMusicDebugKeyErr error
)
var amazonMusicDebugKeySeedParts = [][]byte{
[]byte("spotif"),
[]byte("lac:am"),
[]byte("azon:spotbye:api:v1"),
}
var amazonMusicDebugKeyAAD = []byte{
0x61, 0x6d, 0x61, 0x7a, 0x6f, 0x6e, 0x7c, 0x73, 0x70, 0x6f, 0x74, 0x62,
0x79, 0x65, 0x7c, 0x64, 0x65, 0x62, 0x75, 0x67, 0x7c, 0x76, 0x31,
}
var amazonMusicDebugKeyNonce = []byte{
0x52, 0x1f, 0xa4, 0x9c, 0x13, 0x77, 0x5b, 0xe2, 0x81, 0x44, 0x90, 0x6d,
}
var amazonMusicDebugKeyCiphertext = []byte{
0x5b, 0xf9, 0xc1, 0x2e, 0x58, 0xf8, 0x5b, 0xc0, 0x04, 0x68, 0x7e, 0xff,
0x3d, 0xd6, 0x8b, 0xe3, 0x86, 0x49, 0x6c, 0xfd, 0xc1, 0x49, 0x0b, 0xfb,
}
var amazonMusicDebugKeyTag = []byte{
0x6c, 0x21, 0x98, 0x51, 0xf2, 0x38, 0x4b, 0x4a, 0x23, 0xe1, 0xc6, 0xd7,
0x65, 0x7f, 0xfb, 0xa1,
}
func getAmazonMusicDebugKey() (string, error) {
amazonMusicDebugKeyOnce.Do(func() {
hasher := sha256.New()
for _, part := range amazonMusicDebugKeySeedParts {
hasher.Write(part)
}
block, err := aes.NewCipher(hasher.Sum(nil))
if err != nil {
amazonMusicDebugKeyErr = err
return
}
gcm, err := cipher.NewGCM(block)
if err != nil {
amazonMusicDebugKeyErr = err
return
}
sealed := make([]byte, 0, len(amazonMusicDebugKeyCiphertext)+len(amazonMusicDebugKeyTag))
sealed = append(sealed, amazonMusicDebugKeyCiphertext...)
sealed = append(sealed, amazonMusicDebugKeyTag...)
plaintext, err := gcm.Open(nil, amazonMusicDebugKeyNonce, sealed, amazonMusicDebugKeyAAD)
if err != nil {
amazonMusicDebugKeyErr = err
return
}
amazonMusicDebugKey = string(plaintext)
})
if amazonMusicDebugKeyErr != nil {
return "", amazonMusicDebugKeyErr
}
return amazonMusicDebugKey, nil
}
func NewAmazonDownloader() *AmazonDownloader {
return &AmazonDownloader{
client: &http.Client{
@@ -62,6 +136,12 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
return "", err
}
debugKey, err := getAmazonMusicDebugKey()
if err != nil {
return "", fmt.Errorf("failed to decrypt Amazon debug key: %w", err)
}
req.Header.Set("X-Debug-Key", debugKey)
fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin)
resp, err := a.client.Do(req)
if err != nil {
+156 -1
View File
@@ -2,11 +2,138 @@ package backend
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
)
const legacyTidalAPICacheFile = "tidal-api-urls.json"
func normalizeCustomTidalAPIValue(value interface{}) string {
customAPI, _ := value.(string)
customAPI = strings.TrimRight(strings.TrimSpace(customAPI), "/")
if strings.HasPrefix(customAPI, "https://") {
return customAPI
}
return ""
}
func sanitizeDownloaderValue(value interface{}, allowTidal bool) string {
downloader, _ := value.(string)
switch strings.TrimSpace(strings.ToLower(downloader)) {
case "tidal":
if allowTidal {
return "tidal"
}
return "auto"
case "qobuz":
return "qobuz"
case "amazon":
return "amazon"
default:
return "auto"
}
}
func sanitizeAutoOrderValue(value interface{}, allowTidal bool) string {
autoOrder, _ := value.(string)
allowed := map[string]struct{}{
"qobuz": {},
"amazon": {},
}
fallback := "qobuz-amazon"
if allowTidal {
allowed["tidal"] = struct{}{}
fallback = "tidal-qobuz-amazon"
}
seen := make(map[string]struct{})
parts := make([]string, 0, 3)
for _, rawPart := range strings.Split(strings.TrimSpace(strings.ToLower(autoOrder)), "-") {
part := strings.TrimSpace(rawPart)
if part == "" {
continue
}
if _, ok := allowed[part]; !ok {
continue
}
if _, ok := seen[part]; ok {
continue
}
seen[part] = struct{}{}
parts = append(parts, part)
}
if len(parts) < 2 {
return fallback
}
return strings.Join(parts, "-")
}
func SanitizeSettingsMap(settings map[string]interface{}) map[string]interface{} {
if settings == nil {
return nil
}
sanitized := make(map[string]interface{}, len(settings))
for key, value := range settings {
sanitized[key] = value
}
customAPI := normalizeCustomTidalAPIValue(sanitized["customTidalApi"])
sanitized["customTidalApi"] = customAPI
allowTidal := customAPI != ""
sanitized["downloader"] = sanitizeDownloaderValue(sanitized["downloader"], allowTidal)
sanitized["autoOrder"] = sanitizeAutoOrderValue(sanitized["autoOrder"], allowTidal)
return sanitized
}
func CleanupLegacyTidalPublicAPIState() error {
appDir, err := EnsureAppDir()
if err != nil {
return err
}
cachePath := filepath.Join(appDir, legacyTidalAPICacheFile)
if err := os.Remove(cachePath); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
func SanitizePersistedConfigSettings() error {
configPath, err := GetConfigPath()
if err != nil {
return err
}
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return nil
}
data, err := os.ReadFile(configPath)
if err != nil {
return err
}
var settings map[string]interface{}
if err := json.Unmarshal(data, &settings); err != nil {
return err
}
sanitized := SanitizeSettingsMap(settings)
payload, err := json.MarshalIndent(sanitized, "", " ")
if err != nil {
return err
}
return os.WriteFile(configPath, payload, 0o644)
}
func GetDefaultMusicPath() string {
homeDir, err := os.UserHomeDir()
@@ -47,7 +174,7 @@ func LoadConfigSettings() (map[string]interface{}, error) {
return nil, err
}
return settings, nil
return SanitizeSettingsMap(settings), nil
}
func GetRedownloadWithSuffixSetting() bool {
@@ -60,6 +187,34 @@ func GetRedownloadWithSuffixSetting() bool {
return enabled
}
func GetCustomTidalAPISetting() string {
settings, err := LoadConfigSettings()
if err != nil || settings == nil {
return ""
}
return normalizeCustomTidalAPIValue(settings["customTidalApi"])
}
func normalizeExistingFileCheckMode(value string) string {
switch strings.TrimSpace(strings.ToLower(value)) {
case "isrc", "upc":
return "isrc"
default:
return "filename"
}
}
func GetExistingFileCheckModeSetting() string {
settings, err := LoadConfigSettings()
if err != nil || settings == nil {
return "filename"
}
rawMode, _ := settings["existingFileCheckMode"].(string)
return normalizeExistingFileCheckMode(rawMode)
}
func GetLinkResolverSetting() string {
settings, err := LoadConfigSettings()
if err != nil || settings == nil {
+189 -52
View File
@@ -19,6 +19,11 @@ import (
"golang.org/x/text/unicode/norm"
)
type executableCandidate struct {
path string
source string
}
func ValidateExecutable(path string) error {
cleanedPath := filepath.Clean(path)
if cleanedPath == "" {
@@ -83,6 +88,50 @@ func GetFFmpegDir() (string, error) {
return EnsureAppDir()
}
func copyExecutable(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
return err
}
defer out.Close()
if _, err = io.Copy(out, in); err != nil {
return err
}
if err := out.Sync(); err != nil {
return err
}
return prepareExecutableForUse(dst)
}
func appendExecutableCandidate(candidates []executableCandidate, seen map[string]struct{}, path, source string) []executableCandidate {
cleanedPath := filepath.Clean(strings.TrimSpace(path))
if cleanedPath == "" {
return candidates
}
if _, exists := seen[cleanedPath]; exists {
return candidates
}
seen[cleanedPath] = struct{}{}
return append(candidates, executableCandidate{
path: cleanedPath,
source: source,
})
}
func resolveSystemExecutable(executableName string) string {
if runtime.GOOS == "darwin" {
candidates := []string{
@@ -114,83 +163,163 @@ func resolveSystemExecutable(executableName string) string {
return ""
}
func GetFFmpegPath() (string, error) {
ffmpegDir, err := GetFFmpegDir()
if err != nil {
return "", err
func runExecutableVersionCheck(path string) error {
cmd := exec.Command(path, "-version")
setHideWindow(cmd)
return cmd.Run()
}
func removeMacOSQuarantineAttribute(path string) error {
cmd := exec.Command("xattr", "-d", "com.apple.quarantine", path)
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
if err == nil {
return nil
}
trimmedOutput := strings.TrimSpace(string(output))
lowerOutput := strings.ToLower(trimmedOutput)
if strings.Contains(lowerOutput, "no such xattr") || strings.Contains(lowerOutput, "attribute not found") {
return nil
}
if trimmedOutput != "" {
return fmt.Errorf("%w: %s", err, trimmedOutput)
}
return err
}
func prepareExecutableForUse(path string) error {
cleanedPath := filepath.Clean(strings.TrimSpace(path))
if cleanedPath == "" {
return fmt.Errorf("empty path")
}
if runtime.GOOS == "windows" {
return nil
}
if err := os.Chmod(cleanedPath, 0755); err != nil {
return fmt.Errorf("failed to mark executable: %w", err)
}
if runtime.GOOS == "darwin" {
if err := removeMacOSQuarantineAttribute(cleanedPath); err != nil {
fmt.Printf("[FFmpeg] Warning: failed to remove macOS quarantine from %s: %v\n", cleanedPath, err)
}
}
return nil
}
func resolveExecutablePath(executableName string) (string, string, error) {
ffmpegDir, err := GetFFmpegDir()
if err != nil {
return "", "", err
}
localPath := filepath.Join(ffmpegDir, executableName)
nextDir := filepath.Join(filepath.Dir(ffmpegDir), ".spotiflac-next")
nextPath := filepath.Join(nextDir, executableName)
localExists := false
candidates := make([]executableCandidate, 0, 3)
seen := make(map[string]struct{}, 3)
if systemPath := resolveSystemExecutable(executableName); systemPath != "" {
candidates = appendExecutableCandidate(candidates, seen, systemPath, "system")
}
if _, err := os.Stat(localPath); err == nil {
localExists = true
candidates = appendExecutableCandidate(candidates, seen, localPath, "local")
}
if !localExists {
if _, err := os.Stat(nextPath); err == nil {
if copyErr := copyExecutable(nextPath, localPath); copyErr == nil {
fmt.Printf("[FFmpeg] Copied %s from SpotiFLAC-Next folder\n", executableName)
candidates = appendExecutableCandidate(candidates, seen, localPath, "migrated")
}
}
}
var lastErr error
for _, candidate := range candidates {
if candidate.source != "system" {
if err := prepareExecutableForUse(candidate.path); err != nil {
lastErr = err
fmt.Printf("[FFmpeg] Skipping %s %s: %v\n", candidate.source, candidate.path, err)
continue
}
}
if err := ValidateExecutable(candidate.path); err != nil {
lastErr = err
fmt.Printf("[FFmpeg] Skipping %s %s: %v\n", candidate.source, candidate.path, err)
continue
}
if err := runExecutableVersionCheck(candidate.path); err != nil {
lastErr = err
fmt.Printf("[FFmpeg] Skipping %s %s: %v\n", candidate.source, candidate.path, err)
continue
}
return candidate.path, localPath, nil
}
if len(candidates) > 0 {
if lastErr != nil {
return "", localPath, fmt.Errorf("no working %s executable found: %w", executableName, lastErr)
}
return "", localPath, fmt.Errorf("no working %s executable found", executableName)
}
return "", localPath, fmt.Errorf("%s not found in app directory or system path", executableName)
}
func GetFFmpegPath() (string, error) {
ffmpegName := "ffmpeg"
if runtime.GOOS == "windows" {
ffmpegName = "ffmpeg.exe"
}
if path := resolveSystemExecutable(ffmpegName); path != "" {
return path, nil
}
localPath := filepath.Join(ffmpegDir, ffmpegName)
if _, err := os.Stat(localPath); err == nil {
return localPath, nil
}
return localPath, nil
}
func GetFFprobePath() (string, error) {
ffmpegDir, err := GetFFmpegDir()
path, localPath, err := resolveExecutablePath(ffmpegName)
if err != nil {
if localPath != "" {
return localPath, err
}
return "", err
}
return path, nil
}
func GetFFprobePath() (string, error) {
ffprobeName := "ffprobe"
if runtime.GOOS == "windows" {
ffprobeName = "ffprobe.exe"
}
if path := resolveSystemExecutable(ffprobeName); path != "" {
return path, nil
path, localPath, err := resolveExecutablePath(ffprobeName)
if err != nil {
if localPath != "" {
return localPath, err
}
return "", err
}
localPath := filepath.Join(ffmpegDir, ffprobeName)
if _, err := os.Stat(localPath); err == nil {
return localPath, nil
}
return localPath, fmt.Errorf("ffprobe not found in app directory or system path")
return path, nil
}
func IsFFprobeInstalled() (bool, error) {
ffprobePath, err := GetFFprobePath()
if err != nil {
return false, nil
}
if err := ValidateExecutable(ffprobePath); err != nil {
return false, nil
}
cmd := exec.Command(ffprobePath, "-version")
setHideWindow(cmd)
err = cmd.Run()
_, err := GetFFprobePath()
return err == nil, nil
}
func IsFFmpegInstalled() (bool, error) {
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return false, err
}
if err := ValidateExecutable(ffmpegPath); err != nil {
return false, nil
}
cmd := exec.Command(ffmpegPath, "-version")
setHideWindow(cmd)
err = cmd.Run()
if err != nil {
if _, err := GetFFmpegPath(); err != nil {
return false, nil
}
@@ -507,6 +636,10 @@ func extractZip(zipPath, destDir string) error {
return fmt.Errorf("failed to extract file: %w", err)
}
if err := prepareExecutableForUse(destPath); err != nil {
return fmt.Errorf("failed to prepare extracted executable: %w", err)
}
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
}
@@ -584,6 +717,10 @@ func extractTarXz(tarXzPath, destDir string) error {
return fmt.Errorf("failed to extract file: %w", err)
}
if err := prepareExecutableForUse(destPath); err != nil {
return fmt.Errorf("failed to prepare extracted executable: %w", err)
}
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
}
+77 -6
View File
@@ -1,15 +1,86 @@
package backend
import (
"net/url"
"strings"
)
const amazonMusicAPIBaseURL = "https://amazon.spotbye.qzz.io"
var defaultQobuzStreamAPIBaseURLs = []string{
"https://dab.yeet.su/api/stream?trackId=",
"https://dabmusic.xyz/api/stream?trackId=",
"https://qobuz.spotbye.qzz.io/api/track/",
const (
qobuzWJHEBaseURL = "https://music.wjhe.top"
qobuzWJHESearchAPIURL = qobuzWJHEBaseURL + "/api/music/qobuz/search"
qobuzWJHEStreamAPIURL = qobuzWJHEBaseURL + "/api/music/qobuz/url"
qobuzMusicDLDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download"
qobuzGDStudioAPIURLXYZ = "https://music.gdstudio.xyz/api.php"
qobuzGDStudioAPIURLORG = "https://music.gdstudio.org/api.php"
qobuzGDStudioVersion = "2026.5.10"
)
var defaultQobuzDownloadProviderURLs = []string{
qobuzWJHEStreamAPIURL,
qobuzGDStudioAPIURLXYZ,
qobuzGDStudioAPIURLORG,
qobuzMusicDLDownloadAPIURL,
}
func GetQobuzStreamAPIBaseURLs() []string {
return append([]string(nil), defaultQobuzStreamAPIBaseURLs...)
func GetQobuzDownloadProviderURLs() []string {
return append([]string(nil), defaultQobuzDownloadProviderURLs...)
}
func GetQobuzWJHESearchAPIURL() string {
return qobuzWJHESearchAPIURL
}
func GetQobuzWJHEStreamAPIURL() string {
return qobuzWJHEStreamAPIURL
}
func GetQobuzMusicDLDownloadAPIURL() string {
return qobuzMusicDLDownloadAPIURL
}
func GetQobuzGDStudioAPIURLs() []string {
return []string{qobuzGDStudioAPIURLXYZ, qobuzGDStudioAPIURLORG}
}
func GetQobuzGDStudioPrimaryAPIURL() string {
return qobuzGDStudioAPIURLXYZ
}
func GetQobuzGDStudioFallbackAPIURL() string {
return qobuzGDStudioAPIURLORG
}
func GetQobuzGDStudioSignatureHost(apiURL string) string {
parsed, err := url.Parse(strings.TrimSpace(apiURL))
if err != nil || strings.TrimSpace(parsed.Host) == "" {
return ""
}
return strings.TrimSpace(parsed.Host)
}
func GetQobuzGDStudioVersion() string {
return qobuzGDStudioVersion
}
func IsQobuzWJHEProviderURL(raw string) bool {
candidate := strings.TrimSpace(raw)
return candidate == qobuzWJHEStreamAPIURL || strings.HasPrefix(candidate, qobuzWJHEStreamAPIURL+"?")
}
func IsQobuzMusicDLProviderURL(raw string) bool {
return strings.EqualFold(strings.TrimSpace(raw), qobuzMusicDLDownloadAPIURL)
}
func IsQobuzGDStudioProviderURL(raw string) bool {
candidate := strings.TrimSpace(raw)
for _, apiURL := range GetQobuzGDStudioAPIURLs() {
if candidate == apiURL || strings.HasPrefix(candidate, apiURL+"?") {
return true
}
}
return false
}
func GetAmazonMusicAPIBaseURL() string {
+651 -92
View File
@@ -1,6 +1,12 @@
package backend
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/md5"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
@@ -9,23 +15,14 @@ import (
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
type QobuzDownloader struct {
client *http.Client
appID string
}
type QobuzSearchResponse struct {
Query string `json:"query"`
Tracks struct {
Limit int `json:"limit"`
Offset int `json:"offset"`
Total int `json:"total"`
Items []QobuzTrack `json:"items"`
} `json:"tracks"`
}
type QobuzTrack struct {
@@ -64,8 +61,63 @@ type QobuzTrack struct {
} `json:"album"`
}
type QobuzStreamResponse struct {
URL string `json:"url"`
type qobuzMusicDLRequest struct {
URL string `json:"url"`
Quality string `json:"quality"`
}
type qobuzMusicDLResponse struct {
Success bool `json:"success"`
Type string `json:"type"`
URLType string `json:"url_type"`
TrackID string `json:"track_id"`
Quality string `json:"quality_label"`
DownloadURL string `json:"download_url"`
Message string `json:"message"`
Error string `json:"error"`
}
type qobuzPublicSearchResponse struct {
Tracks struct {
Total int `json:"total"`
Items []QobuzTrack `json:"items"`
} `json:"tracks"`
}
const qobuzProbeTrackID int64 = 341032040
var (
qobuzMusicDLDebugKeyOnce sync.Once
qobuzMusicDLDebugKey string
qobuzMusicDLDebugKeyErr error
qobuzStreamingURLPattern = regexp.MustCompile(`https?://[^\s"'<>\\)]+`)
)
var qobuzMusicDLDebugKeySeedParts = [][]byte{
{0x73, 0x70, 0x6f, 0x74, 0x69, 0x66},
{0x6c, 0x61, 0x63, 0x3a, 0x71, 0x6f},
{0x62, 0x75, 0x7a, 0x3a, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x64, 0x6c, 0x3a, 0x76, 0x31},
}
var qobuzMusicDLDebugKeyAAD = []byte{
0x71, 0x6f, 0x62, 0x75, 0x7a, 0x7c, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x64,
0x6c, 0x7c, 0x64, 0x65, 0x62, 0x75, 0x67, 0x7c, 0x76, 0x31,
}
var qobuzMusicDLDebugKeyNonce = []byte{
0x91, 0x2a, 0x5c, 0x77, 0x0f, 0x33, 0xa8, 0x14, 0x62, 0x9d, 0xce, 0x41,
}
var qobuzMusicDLDebugKeyCiphertext = []byte{
0xf3, 0x4a, 0x83, 0x45, 0x24, 0xb6, 0x22, 0xaf, 0xd6, 0xc3, 0x6e, 0x2d,
0x56, 0xd1, 0xbb, 0x0b, 0xe9, 0x1b, 0x4f, 0x1c, 0x5f, 0x41, 0x55, 0xc2,
0xc6, 0xdf, 0xad, 0x21, 0x58, 0xfe, 0xd5, 0xb8, 0x2d, 0x29, 0xf9, 0x9e,
0x6f, 0xd6,
}
var qobuzMusicDLDebugKeyTag = []byte{
0x69, 0x0c, 0x42, 0x70, 0x14, 0x83, 0xff, 0x14, 0xc8, 0xbe, 0x17, 0x00,
0x69, 0xb1, 0xfe, 0xbb,
}
func NewQobuzDownloader() *QobuzDownloader {
@@ -73,119 +125,625 @@ func NewQobuzDownloader() *QobuzDownloader {
client: &http.Client{
Timeout: 60 * time.Second,
},
appID: qobuzDefaultAPIAppID,
}
}
func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
func previewQobuzResponseBody(body []byte, maxLen int) string {
preview := strings.TrimSpace(string(body))
if len(preview) > maxLen {
return preview[:maxLen] + "..."
}
return preview
}
func buildQobuzOpenTrackURL(trackID int64) string {
return fmt.Sprintf("https://open.qobuz.com/track/%d", trackID)
}
func getQobuzMusicDLDebugKey() (string, error) {
qobuzMusicDLDebugKeyOnce.Do(func() {
hasher := sha256.New()
for _, part := range qobuzMusicDLDebugKeySeedParts {
hasher.Write(part)
}
block, err := aes.NewCipher(hasher.Sum(nil))
if err != nil {
qobuzMusicDLDebugKeyErr = err
return
}
gcm, err := cipher.NewGCM(block)
if err != nil {
qobuzMusicDLDebugKeyErr = err
return
}
sealed := make([]byte, 0, len(qobuzMusicDLDebugKeyCiphertext)+len(qobuzMusicDLDebugKeyTag))
sealed = append(sealed, qobuzMusicDLDebugKeyCiphertext...)
sealed = append(sealed, qobuzMusicDLDebugKeyTag...)
plaintext, err := gcm.Open(nil, qobuzMusicDLDebugKeyNonce, sealed, qobuzMusicDLDebugKeyAAD)
if err != nil {
qobuzMusicDLDebugKeyErr = err
return
}
qobuzMusicDLDebugKey = string(plaintext)
})
if qobuzMusicDLDebugKeyErr != nil {
return "", qobuzMusicDLDebugKeyErr
}
return qobuzMusicDLDebugKey, nil
}
func firstNonEmptyQobuzValue(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
func normalizeQobuzSearchValue(value string) string {
replacer := strings.NewReplacer(
"&", " and ",
"feat.", " ",
"ft.", " ",
"/", " ",
"-", " ",
"_", " ",
)
normalized := strings.ToLower(strings.TrimSpace(value))
normalized = replacer.Replace(normalized)
return strings.Join(strings.Fields(normalized), " ")
}
func qobuzTrackDisplayArtist(track QobuzTrack) string {
return firstNonEmptyQobuzValue(track.Performer.Name, track.Album.Artist.Name)
}
func qobuzTrackSupportsHiRes(track QobuzTrack) bool {
if track.Hires || track.HiresStreamable {
return true
}
return track.MaximumBitDepth >= 24 || track.MaximumSamplingRate > 48
}
func scoreQobuzSearchCandidate(track QobuzTrack, spotifyTrackName string, spotifyArtistName string, spotifyAlbumName string) int {
score := 0
titleNeedle := normalizeQobuzSearchValue(spotifyTrackName)
titleHaystack := normalizeQobuzSearchValue(track.Title)
switch {
case titleNeedle != "" && titleHaystack == titleNeedle:
score += 1000
case titleNeedle != "" && (strings.Contains(titleHaystack, titleNeedle) || strings.Contains(titleNeedle, titleHaystack)):
score += 500
}
artistNeedle := normalizeQobuzSearchValue(spotifyArtistName)
artistHaystack := normalizeQobuzSearchValue(qobuzTrackDisplayArtist(track))
switch {
case artistNeedle != "" && artistHaystack == artistNeedle:
score += 300
case artistNeedle != "" && artistHaystack != "" && (strings.Contains(artistHaystack, artistNeedle) || strings.Contains(artistNeedle, artistHaystack)):
score += 180
}
albumNeedle := normalizeQobuzSearchValue(spotifyAlbumName)
albumHaystack := normalizeQobuzSearchValue(track.Album.Title)
switch {
case albumNeedle != "" && albumHaystack == albumNeedle:
score += 150
case albumNeedle != "" && albumHaystack != "" && (strings.Contains(albumHaystack, albumNeedle) || strings.Contains(albumNeedle, albumHaystack)):
score += 90
}
if qobuzTrackSupportsHiRes(track) {
score += 40
} else if track.MaximumBitDepth >= 16 {
score += 20
}
return score
}
func mapQobuzWJHEQuality(quality string) (int, string) {
switch strings.TrimSpace(quality) {
case "27", "7":
return 2000, "flac"
case "", "6":
return 1000, "flac"
default:
return 320, "mp3"
}
}
func buildQobuzWJHEDownloadURL(trackID int64, quality string) string {
wjheQuality, wjheFormat := mapQobuzWJHEQuality(quality)
params := url.Values{
"ID": {strconv.FormatInt(trackID, 10)},
"quality": {strconv.Itoa(wjheQuality)},
"format": {wjheFormat},
}
return GetQobuzWJHEStreamAPIURL() + "?" + params.Encode()
}
func qobuzURLLooksStreamable(raw string) bool {
candidate := strings.TrimSpace(raw)
if candidate == "" {
return false
}
parsed, err := url.Parse(candidate)
if err != nil {
return false
}
return (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != ""
}
func findQobuzStreamingURLInPayload(payload interface{}) string {
switch value := payload.(type) {
case string:
candidate := strings.ReplaceAll(strings.TrimSpace(value), `\/`, `/`)
if qobuzURLLooksStreamable(candidate) {
return candidate
}
case []interface{}:
for _, item := range value {
if url := findQobuzStreamingURLInPayload(item); url != "" {
return url
}
}
case map[string]interface{}:
for _, key := range []string{"download_url", "url", "play_url", "stream_url", "link", "file"} {
if nested, ok := value[key]; ok {
if url := findQobuzStreamingURLInPayload(nested); url != "" {
return url
}
}
}
for _, nested := range value {
if url := findQobuzStreamingURLInPayload(nested); url != "" {
return url
}
}
}
return ""
}
func extractQobuzStreamingURL(body []byte) string {
trimmed := strings.TrimSpace(string(body))
if trimmed == "" {
return ""
}
var directResp struct {
URL string `json:"url"`
DownloadURL string `json:"download_url"`
Data struct {
URL string `json:"url"`
DownloadURL string `json:"download_url"`
} `json:"data"`
}
if err := json.Unmarshal(body, &directResp); err == nil {
for _, candidate := range []string{
directResp.DownloadURL,
directResp.URL,
directResp.Data.DownloadURL,
directResp.Data.URL,
} {
if qobuzURLLooksStreamable(candidate) {
return candidate
}
}
}
var genericPayload interface{}
if err := json.Unmarshal(body, &genericPayload); err == nil {
if streamURL := findQobuzStreamingURLInPayload(genericPayload); streamURL != "" {
return streamURL
}
}
if openIdx := strings.Index(trimmed, "("); openIdx >= 0 {
if closeIdx := strings.LastIndex(trimmed, ")"); closeIdx > openIdx+1 {
callbackBody := strings.TrimSpace(trimmed[openIdx+1 : closeIdx])
if streamURL := extractQobuzStreamingURL([]byte(callbackBody)); streamURL != "" {
return streamURL
}
}
}
for _, match := range qobuzStreamingURLPattern.FindAllString(trimmed, -1) {
candidate := strings.ReplaceAll(match, `\/`, `/`)
if qobuzURLLooksStreamable(candidate) {
return candidate
}
}
return ""
}
func newQobuzNoRedirectClient(base *http.Client) *http.Client {
if base == nil {
return &http.Client{
Timeout: 20 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
cloned := *base
if cloned.Timeout == 0 {
cloned.Timeout = 20 * time.Second
}
cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
return &cloned
}
func (q *QobuzDownloader) searchByISRC(isrc string, spotifyTrackName string, spotifyArtistName string, spotifyAlbumName string) (*QobuzTrack, error) {
if strings.HasPrefix(isrc, "qobuz_") {
trackID := strings.TrimPrefix(isrc, "qobuz_")
trackID := strings.TrimSpace(strings.TrimPrefix(isrc, "qobuz_"))
resp, err := doQobuzSignedRequest(http.MethodGet, "track/get", url.Values{"track_id": {trackID}}, q.client)
if err != nil {
return nil, fmt.Errorf("failed to fetch track: %w", err)
return nil, fmt.Errorf("failed to fetch track from Qobuz public API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("Qobuz public API track/get returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
}
var trackResp QobuzTrack
if err := json.NewDecoder(resp.Body).Decode(&trackResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, fmt.Errorf("failed to decode Qobuz public track/get response: %w", err)
}
return &trackResp, nil
}
resp, err := doQobuzSignedRequest(http.MethodGet, "track/search", url.Values{
"query": {isrc},
"limit": {"1"},
}, q.client)
queries := []string{strings.TrimSpace(isrc)}
if fallbackQuery := strings.TrimSpace(strings.Join([]string{spotifyTrackName, spotifyArtistName}, " ")); fallbackQuery != "" {
queries = append(queries, fallbackQuery)
}
var lastErr error
for _, query := range queries {
if strings.TrimSpace(query) == "" {
continue
}
var searchResp qobuzPublicSearchResponse
if err := doQobuzSignedJSONRequest("track/search", url.Values{
"query": {strings.TrimSpace(query)},
"limit": {"10"},
}, &searchResp); err != nil {
lastErr = fmt.Errorf("failed to search Qobuz public API: %w", err)
continue
}
if searchResp.Tracks.Total == 0 || len(searchResp.Tracks.Items) == 0 {
lastErr = fmt.Errorf("track not found for query: %s", query)
continue
}
bestIndex := 0
bestScore := -1
for idx, candidate := range searchResp.Tracks.Items {
score := scoreQobuzSearchCandidate(candidate, spotifyTrackName, spotifyArtistName, spotifyAlbumName)
if idx == 0 || score > bestScore {
bestIndex = idx
bestScore = score
}
}
selected := searchResp.Tracks.Items[bestIndex]
return &selected, nil
}
if lastErr == nil {
lastErr = fmt.Errorf("track not found for ISRC: %s", isrc)
}
return nil, lastErr
}
func (q *QobuzDownloader) DownloadFromWJHE(trackID int64, quality string) (string, error) {
apiURL := buildQobuzWJHEDownloadURL(trackID, quality)
client := newQobuzNoRedirectClient(q.client)
req, err := NewRequestWithDefaultHeaders(http.MethodHead, apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to search track: %w", err)
return "", fmt.Errorf("failed to create WJHE request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to reach WJHE: %w", err)
}
if resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotImplemented {
resp.Body.Close()
req, err = NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create WJHE fallback request: %w", err)
}
resp, err = client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to reach WJHE with GET fallback: %w", err)
}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
if location := strings.TrimSpace(resp.Header.Get("Location")); qobuzURLLooksStreamable(location) {
return location, nil
}
var searchResp QobuzSearchResponse
body, err := io.ReadAll(resp.Body)
body, err := io.ReadAll(io.LimitReader(resp.Body, 128*1024))
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
return "", fmt.Errorf("failed to read WJHE response: %w", err)
}
if len(body) == 0 {
return nil, fmt.Errorf("API returned empty response")
if streamURL := extractQobuzStreamingURL(body); streamURL != "" {
return streamURL, nil
}
if err := json.Unmarshal(body, &searchResp); err != nil {
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
if resp.Request != nil && resp.Request.URL != nil {
if streamURL := strings.TrimSpace(resp.Request.URL.String()); streamURL != "" && streamURL != apiURL && qobuzURLLooksStreamable(streamURL) {
return streamURL, nil
}
return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
}
if len(searchResp.Tracks.Items) == 0 {
return nil, fmt.Errorf("track not found for ISRC: %s", isrc)
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
return "", fmt.Errorf("WJHE returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
}
return &searchResp.Tracks.Items[0], nil
return "", fmt.Errorf("WJHE response did not include a stream URL")
}
func buildQobuzAPIURL(apiBase string, trackID int64, quality string) string {
if strings.Contains(apiBase, "qobuz.spotbye.qzz.io") {
return fmt.Sprintf("%s%d?quality=%s", apiBase, trackID, quality)
func qobuzGDStudioPaddedVersion() string {
parts := strings.Split(GetQobuzGDStudioVersion(), ".")
for idx, part := range parts {
part = strings.TrimSpace(part)
if len(part) == 1 {
part = "0" + part
}
parts[idx] = part
}
return fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
return strings.Join(parts, "")
}
func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, quality string) (string, error) {
apiURL := buildQobuzAPIURL(apiBase, trackID, quality)
req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
func qobuzGDStudioEscapedValue(value string) string {
return strings.ReplaceAll(url.QueryEscape(strings.TrimSpace(value)), "+", "%20")
}
func (q *QobuzDownloader) getQobuzGDStudioTS9(apiURL string) string {
fallback := strconv.FormatInt(time.Now().UnixMilli(), 10)
if len(fallback) >= 9 {
fallback = fallback[:9]
}
client := q.client
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
signatureHost := GetQobuzGDStudioSignatureHost(apiURL)
if signatureHost == "" {
return fallback
}
req, err := NewRequestWithDefaultHeaders(http.MethodGet, fmt.Sprintf("https://%s/time", signatureHost), nil)
if err != nil {
return "", err
return fallback
}
resp, err := client.Do(req)
if err != nil {
return fallback
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64))
if err != nil {
return fallback
}
timestamp := strings.TrimSpace(string(body))
if len(timestamp) >= 9 {
return timestamp[:9]
}
return fallback
}
func buildQobuzGDStudioSignature(apiURL string, value string, ts9 string) string {
signatureHost := GetQobuzGDStudioSignatureHost(apiURL)
signatureBase := fmt.Sprintf("%s|%s|%s|%s", signatureHost, qobuzGDStudioPaddedVersion(), ts9, qobuzGDStudioEscapedValue(value))
sum := md5.Sum([]byte(signatureBase))
digest := hex.EncodeToString(sum[:])
return strings.ToUpper(digest[len(digest)-8:])
}
func mapQobuzGDStudioBitrate(quality string) string {
switch strings.TrimSpace(quality) {
case "27", "7":
return "999"
case "", "6":
return "740"
default:
return "320"
}
}
func (q *QobuzDownloader) DownloadFromGDStudio(trackID int64, quality string, apiURL string) (string, error) {
apiURL = strings.TrimSpace(apiURL)
if apiURL == "" {
apiURL = GetQobuzGDStudioPrimaryAPIURL()
}
signatureHost := GetQobuzGDStudioSignatureHost(apiURL)
if signatureHost == "" {
return "", fmt.Errorf("GDStudio API URL is invalid: %s", apiURL)
}
trackIDString := strconv.FormatInt(trackID, 10)
ts9 := q.getQobuzGDStudioTS9(apiURL)
payload := url.Values{
"types": {"url"},
"id": {trackIDString},
"source": {"qobuz"},
"br": {mapQobuzGDStudioBitrate(quality)},
"s": {buildQobuzGDStudioSignature(apiURL, trackIDString, ts9)},
}
req, err := NewRequestWithDefaultHeaders(http.MethodPost, apiURL, strings.NewReader(payload.Encode()))
if err != nil {
return "", fmt.Errorf("failed to create GDStudio request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
req.Header.Set("Origin", fmt.Sprintf("https://%s", signatureHost))
req.Header.Set("Referer", fmt.Sprintf("https://%s/", signatureHost))
resp, err := q.client.Do(req)
if err != nil {
return "", err
return "", fmt.Errorf("failed to reach GDStudio: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("status %d", resp.StatusCode)
body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
if err != nil {
return "", fmt.Errorf("failed to read GDStudio response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("GDStudio returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
}
streamURL := extractQobuzStreamingURL(body)
if streamURL == "" {
return "", fmt.Errorf("GDStudio response did not include a stream URL: %s", previewQobuzResponseBody(body, 256))
}
return streamURL, nil
}
func (q *QobuzDownloader) DownloadFromMusicDL(trackID int64, quality string) (string, error) {
if strings.TrimSpace(quality) == "" {
quality = "6"
}
debugKey, err := getQobuzMusicDLDebugKey()
if err != nil {
return "", fmt.Errorf("failed to decrypt MusicDL debug key: %w", err)
}
payload, err := json.Marshal(qobuzMusicDLRequest{
URL: buildQobuzOpenTrackURL(trackID),
Quality: strings.TrimSpace(quality),
})
if err != nil {
return "", fmt.Errorf("failed to encode MusicDL request: %w", err)
}
req, err := NewRequestWithDefaultHeaders(http.MethodPost, GetQobuzMusicDLDownloadAPIURL(), bytes.NewReader(payload))
if err != nil {
return "", fmt.Errorf("failed to create MusicDL request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Debug-Key", debugKey)
resp, err := q.client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to reach MusicDL: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
return "", fmt.Errorf("failed to read MusicDL response: %w", err)
}
if len(body) == 0 {
return "", fmt.Errorf("empty body")
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("MusicDL returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
}
var streamResp QobuzStreamResponse
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
return streamResp.URL, nil
var downloadResp qobuzMusicDLResponse
if err := json.Unmarshal(body, &downloadResp); err != nil {
return "", fmt.Errorf("failed to decode MusicDL response: %w (%s)", err, previewQobuzResponseBody(body, 256))
}
var nestedResp struct {
Data struct {
URL string `json:"url"`
} `json:"data"`
}
if err := json.Unmarshal(body, &nestedResp); err == nil && nestedResp.Data.URL != "" {
return nestedResp.Data.URL, nil
if !downloadResp.Success {
message := strings.TrimSpace(downloadResp.Error)
if message == "" {
message = strings.TrimSpace(downloadResp.Message)
}
if message == "" {
message = "MusicDL reported failure"
}
return "", fmt.Errorf("%s", message)
}
return "", fmt.Errorf("invalid response")
downloadURL := strings.TrimSpace(downloadResp.DownloadURL)
if downloadURL == "" {
return "", fmt.Errorf("MusicDL response did not include a download_url")
}
return downloadURL, nil
}
func CheckQobuzMusicDLStatusDetailed(client *http.Client) error {
if client == nil {
client = &http.Client{Timeout: 4 * time.Second}
}
downloader := &QobuzDownloader{client: client}
_, err := downloader.DownloadFromMusicDL(qobuzProbeTrackID, "27")
return err
}
func CheckQobuzMusicDLStatus(client *http.Client) bool {
return CheckQobuzMusicDLStatusDetailed(client) == nil
}
func CheckQobuzWJHEStatusDetailed(client *http.Client) error {
if client == nil {
client = &http.Client{Timeout: 4 * time.Second}
}
downloader := &QobuzDownloader{client: client}
_, err := downloader.DownloadFromWJHE(qobuzProbeTrackID, "27")
return err
}
func CheckQobuzWJHEStatus(client *http.Client) bool {
return CheckQobuzWJHEStatusDetailed(client) == nil
}
func CheckQobuzGDStudioAPIStatusDetailed(client *http.Client, apiURL string) error {
if client == nil {
client = &http.Client{Timeout: 4 * time.Second}
}
downloader := &QobuzDownloader{client: client}
_, err := downloader.DownloadFromGDStudio(qobuzProbeTrackID, "27", apiURL)
return err
}
func CheckQobuzGDStudioAPIStatus(client *http.Client, apiURL string) bool {
return CheckQobuzGDStudioAPIStatusDetailed(client, apiURL) == nil
}
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFallback bool) (string, error) {
@@ -196,41 +754,36 @@ 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)
standardAPIs := prioritizeProviders("qobuz", GetQobuzStreamAPIBaseURLs())
downloadFunc := func(qual string) (string, error) {
type Provider struct {
Name string
API string
Func func() (string, error)
}
var providers []Provider
for _, api := range standardAPIs {
currentAPI := api
providers = append(providers, Provider{
Name: "Standard(" + currentAPI + ")",
API: currentAPI,
Func: func() (string, error) {
return q.DownloadFromStandard(currentAPI, trackID, qual)
},
})
attemptMap := make(map[string]qobuzProviderAttempt)
attemptIDs := make([]string, 0, len(GetQobuzDownloadProviderURLs()))
for _, provider := range q.getQobuzDownloadProviders() {
for _, attempt := range provider.Attempts(trackID, qual) {
attemptMap[attempt.ID] = attempt
attemptIDs = append(attemptIDs, attempt.ID)
}
}
orderedProviderIDs := prioritizeProviders("qobuz", attemptIDs)
orderedProviderIDs = moveQobuzAttemptIDsLast(orderedProviderIDs, GetQobuzMusicDLDownloadAPIURL())
var lastErr error
for _, p := range providers {
fmt.Printf("Trying Provider: %s (Quality: %s)...\n", p.Name, qual)
for _, providerID := range orderedProviderIDs {
attempt, ok := attemptMap[providerID]
if !ok {
continue
}
url, err := p.Func()
fmt.Printf("Trying Provider: %s (Quality: %s)...\n", attempt.Name, qual)
url, err := attempt.Download()
if err == nil {
fmt.Printf("✓ Success\n")
recordProviderSuccess("qobuz", p.API)
recordProviderSuccess("qobuz", attempt.ID)
return url, nil
}
fmt.Printf("Provider failed: %v\n", err)
recordProviderFailure("qobuz", p.API)
recordProviderFailure("qobuz", attempt.ID)
lastErr = err
}
return "", lastErr
@@ -443,7 +996,7 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena
}
}
track, err := q.searchByISRC(isrc)
track, err := q.searchByISRC(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName)
if err != nil {
return "", err
}
@@ -457,7 +1010,13 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena
qualityInfo := "Standard"
if track.Hires {
qualityInfo = fmt.Sprintf("Hi-Res (%d-bit / %.1f kHz)", track.MaximumBitDepth, track.MaximumSamplingRate)
if track.MaximumBitDepth > 0 && track.MaximumSamplingRate > 0 {
qualityInfo = fmt.Sprintf("Hi-Res (%d-bit / %.1f kHz)", track.MaximumBitDepth, track.MaximumSamplingRate)
} else if track.MaximumBitDepth > 0 {
qualityInfo = fmt.Sprintf("Hi-Res available (%d-bit)", track.MaximumBitDepth)
} else {
qualityInfo = "Hi-Res available"
}
}
fmt.Printf("Quality: %s\n", qualityInfo)
+106
View File
@@ -0,0 +1,106 @@
package backend
type qobuzDownloadProvider interface {
Name() string
Attempts(trackID int64, quality string) []qobuzProviderAttempt
}
type qobuzProviderAttempt struct {
Name string
ID string
Download func() (string, error)
}
type QobuzProviderWJHE struct {
downloader *QobuzDownloader
}
func (p QobuzProviderWJHE) Name() string {
return "QobuzProviderWJHE"
}
func (p QobuzProviderWJHE) Attempts(trackID int64, quality string) []qobuzProviderAttempt {
return []qobuzProviderAttempt{
{
Name: p.Name(),
ID: GetQobuzWJHEStreamAPIURL(),
Download: func() (string, error) {
return p.downloader.DownloadFromWJHE(trackID, quality)
},
},
}
}
type QobuzProviderMusicDL struct {
downloader *QobuzDownloader
}
func (p QobuzProviderMusicDL) Name() string {
return "QobuzProviderMusicDL"
}
func (p QobuzProviderMusicDL) Attempts(trackID int64, quality string) []qobuzProviderAttempt {
return []qobuzProviderAttempt{
{
Name: p.Name(),
ID: GetQobuzMusicDLDownloadAPIURL(),
Download: func() (string, error) {
return p.downloader.DownloadFromMusicDL(trackID, quality)
},
},
}
}
type QobuzProviderGDStudio struct {
downloader *QobuzDownloader
}
func (p QobuzProviderGDStudio) Name() string {
return "QobuzProviderGDStudio"
}
func (p QobuzProviderGDStudio) Attempts(trackID int64, quality string) []qobuzProviderAttempt {
attempts := make([]qobuzProviderAttempt, 0, len(GetQobuzGDStudioAPIURLs()))
for _, apiURL := range GetQobuzGDStudioAPIURLs() {
currentAPIURL := apiURL
attempts = append(attempts, qobuzProviderAttempt{
Name: p.Name(),
ID: currentAPIURL,
Download: func() (string, error) {
return p.downloader.DownloadFromGDStudio(trackID, quality, currentAPIURL)
},
})
}
return attempts
}
func (q *QobuzDownloader) getQobuzDownloadProviders() []qobuzDownloadProvider {
return []qobuzDownloadProvider{
QobuzProviderWJHE{downloader: q},
QobuzProviderGDStudio{downloader: q},
QobuzProviderMusicDL{downloader: q},
}
}
func moveQobuzAttemptIDsLast(providerIDs []string, lastIDs ...string) []string {
if len(providerIDs) == 0 || len(lastIDs) == 0 {
return append([]string(nil), providerIDs...)
}
lastIDSet := make(map[string]struct{}, len(lastIDs))
for _, providerID := range lastIDs {
lastIDSet[providerID] = struct{}{}
}
ordered := make([]string, 0, len(providerIDs))
trailing := make([]string, 0, len(providerIDs))
for _, providerID := range providerIDs {
if _, ok := lastIDSet[providerID]; ok {
trailing = append(trailing, providerID)
continue
}
ordered = append(ordered, providerID)
}
return append(ordered, trailing...)
}
+174 -34
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
@@ -47,15 +48,154 @@ type TidalBTSManifest struct {
URLs []string `json:"urls"`
}
func NewTidalDownloader(apiURL string) *TidalDownloader {
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
if apiURL == "" {
apis, err := GetRotatedTidalAPIList()
if err == nil && len(apis) > 0 {
apiURL = apis[0]
func getConfiguredTidalAPIAttemptList() ([]string, error) {
customAPI := GetCustomTidalAPISetting()
if customAPI == "" {
return nil, fmt.Errorf("no configured custom tidal api instance")
}
return []string{customAPI}, nil
}
func buildTidalOutputPath(outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyTrackNumber, spotifyDiscNumber int, isrcOverride string, useFirstArtistOnly bool) (string, bool, error) {
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", false, fmt.Errorf("directory error: %w", err)
}
}
artistNameForFile := sanitizeFilename(spotifyArtistName)
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
if useFirstArtistOnly {
artistNameForFile = sanitizeFilename(GetFirstArtist(spotifyArtistName))
albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
}
trackTitleForFile := sanitizeFilename(spotifyTrackName)
albumTitleForFile := sanitizeFilename(spotifyAlbumName)
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrcOverride)
outputFilename := filepath.Join(outputDir, filename)
outputFilename, alreadyExists := ResolveOutputPathForDownload(outputFilename, GetRedownloadWithSuffixSetting())
return outputFilename, alreadyExists, nil
}
func finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useSingleGenre bool, embedGenre bool) {
trackTitle := spotifyTrackName
artistName := spotifyArtistName
albumTitle := spotifyAlbumName
type mbResult struct {
ISRC string
Metadata Metadata
}
metaChan := make(chan mbResult, 1)
if embedGenre && spotifyURL != "" {
go func() {
res := mbResult{}
var isrc string
parts := strings.Split(spotifyURL, "/")
if len(parts) > 0 {
sID := strings.Split(parts[len(parts)-1], "?")[0]
if sID != "" {
client := NewSongLinkClient()
if val, err := client.GetISRC(sID); err == nil {
isrc = val
}
}
}
res.ISRC = isrc
if isrc != "" {
if ShouldSkipMusicBrainzMetadataFetch() {
fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.")
} else {
fmt.Println("Fetching MusicBrainz metadata...")
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil {
res.Metadata = fetchedMeta
fmt.Println("✓ MusicBrainz metadata fetched")
} else {
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
}
}
}
metaChan <- res
}()
} else {
close(metaChan)
}
isrc := strings.TrimSpace(isrcOverride)
var mbMeta Metadata
if spotifyURL != "" {
result := <-metaChan
if isrc == "" {
isrc = result.ISRC
}
mbMeta = result.Metadata
}
upc := ""
if spotifyURL != "" {
if identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyURL); err == nil || identifiers.ISRC != "" || identifiers.UPC != "" {
if strings.TrimSpace(isrc) == "" && strings.TrimSpace(identifiers.ISRC) != "" {
isrc = strings.TrimSpace(identifiers.ISRC)
}
upc = strings.TrimSpace(identifiers.UPC)
}
}
fmt.Println("Adding metadata...")
coverPath := ""
if spotifyCoverURL != "" {
coverPath = outputFilename + ".cover.jpg"
coverClient := NewCoverClient()
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
coverPath = ""
} else {
defer os.Remove(coverPath)
fmt.Println("Spotify cover downloaded")
}
}
trackNumberToEmbed := spotifyTrackNumber
if trackNumberToEmbed == 0 {
trackNumberToEmbed = 1
}
metadata := Metadata{
Title: trackTitle,
Artist: artistName,
Album: albumTitle,
AlbumArtist: spotifyAlbumArtist,
Date: spotifyReleaseDate,
TrackNumber: trackNumberToEmbed,
TotalTracks: spotifyTotalTracks,
DiscNumber: spotifyDiscNumber,
TotalDiscs: spotifyTotalDiscs,
URL: spotifyURL,
Comment: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Composer: spotifyComposer,
Separator: metadataSeparator,
Description: "https://github.com/spotbye/SpotiFLAC",
ISRC: isrc,
UPC: upc,
Genre: mbMeta.Genre,
}
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
fmt.Printf("Tagging failed: %v\n", err)
} else {
fmt.Println("Metadata saved")
}
}
func NewTidalDownloader(apiURL string) *TidalDownloader {
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
return &TidalDownloader{
client: &http.Client{
Timeout: 5 * time.Second,
@@ -67,7 +207,7 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
}
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
apis, err := GetRotatedTidalAPIList()
apis, err := getConfiguredTidalAPIAttemptList()
if err == nil && len(apis) > 0 {
return apis, nil
}
@@ -112,6 +252,9 @@ func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
fmt.Println("Fetching URL...")
if strings.TrimSpace(t.apiURL) == "" {
return "", fmt.Errorf("no configured custom tidal api instance")
}
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality)
fmt.Printf("Tidal API URL: %s\n", url)
@@ -173,10 +316,10 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string,
return "", fmt.Errorf("download URL not found in response")
}
func (t *TidalDownloader) DownloadFile(url, filepath string) error {
func (t *TidalDownloader) DownloadFile(url, filepath string, quality string) error {
if strings.HasPrefix(url, "MANIFEST:") {
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath)
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath, quality)
}
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
@@ -213,12 +356,18 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error {
return nil
}
func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) error {
func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string, quality string) error {
directURL, initURL, mediaURLs, mimeType, err := parseManifest(manifestB64)
if err != nil {
return fmt.Errorf("failed to parse manifest: %w", err)
}
isLosslessRequested := quality == "LOSSLESS" || quality == "HI_RES" || quality == "HI_RES_LOSSLESS"
isActualLossless := strings.Contains(strings.ToLower(mimeType), "flac") || mimeType == ""
if isLosslessRequested && !isActualLossless {
return fmt.Errorf("requested %s quality but Tidal provided lossy format (%s). Aborting download", quality, mimeType)
}
client := &http.Client{
Timeout: 120 * time.Second,
}
@@ -433,15 +582,10 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
}
fmt.Printf("Downloading to: %s\n", outputFilename)
if err := t.DownloadFile(downloadURL, outputFilename); err != nil {
if err := t.DownloadFile(downloadURL, outputFilename, quality); err != nil {
cleanupTidalDownloadArtifacts(outputFilename)
return outputFilename, err
}
if t.apiURL != "" {
if err := RememberTidalAPIUsage(t.apiURL); err != nil {
fmt.Printf("Warning: failed to persist last used Tidal API: %v\n", err)
}
}
finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre)
@@ -493,7 +637,10 @@ func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameF
return "", fmt.Errorf("songlink/songstats couldn't find Tidal URL: %w", err)
}
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)
if t.apiURL == "" {
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)
}
type SegmentTemplate struct {
@@ -550,10 +697,12 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
var mpd MPD
var segTemplate *SegmentTemplate
var dashMimeType string
if err := xml.Unmarshal(manifestBytes, &mpd); err == nil {
var selectedBandwidth int
var selectedCodecs string
var selectedMimeType string
for _, as := range mpd.Period.AdaptationSets {
@@ -562,6 +711,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
if segTemplate == nil {
segTemplate = as.SegmentTemplate
selectedCodecs = as.Codecs
selectedMimeType = as.MimeType
}
}
@@ -576,6 +726,8 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
} else {
selectedCodecs = as.Codecs
}
selectedMimeType = as.MimeType
}
}
}
@@ -583,6 +735,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
if selectedBandwidth > 0 {
fmt.Printf("Selected stream: Codec=%s, Bandwidth=%d bps\n", selectedCodecs, selectedBandwidth)
dashMimeType = fmt.Sprintf("%s; codecs=\"%s\"", selectedMimeType, selectedCodecs)
}
}
@@ -608,7 +761,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
mediaURLs = append(mediaURLs, mediaURL)
}
return "", initURL, mediaURLs, "", nil
return "", initURL, mediaURLs, dashMimeType, nil
}
fmt.Println("Using regex fallback for DASH manifest...")
@@ -655,7 +808,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
mediaURLs = append(mediaURLs, mediaURL)
}
return "", initURL, mediaURLs, "", nil
return "", initURL, mediaURLs, dashMimeType, nil
}
func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename string, quality string, allowFallback bool) (string, error) {
@@ -684,7 +837,7 @@ func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename
}
func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilename string, quality string, refreshed bool) (string, error) {
apis, err := GetRotatedTidalAPIList()
apis, err := getConfiguredTidalAPIAttemptList()
if err != nil && len(apis) == 0 {
return "", fmt.Errorf("failed to load tidal api list: %w", err)
}
@@ -706,29 +859,16 @@ func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilena
continue
}
if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil {
if err := downloader.DownloadFile(downloadURL, outputFilename, quality); err != nil {
lastErr = err
cleanupTidalDownloadArtifacts(outputFilename)
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err))
continue
}
if err := RememberTidalAPIUsage(apiURL); err != nil {
fmt.Printf("Warning: failed to persist last used Tidal API: %v\n", err)
}
return apiURL, nil
}
if !refreshed {
if _, refreshErr := RefreshTidalAPIList(true); refreshErr != nil {
errors = append(errors, fmt.Sprintf("gist refresh failed: %v", refreshErr))
} else {
fmt.Println("All cached Tidal APIs failed, refreshed gist list and retrying...")
return t.tryDownloadAcrossTidalAPIs(trackID, outputFilename, quality, true)
}
}
if lastErr == nil {
lastErr = fmt.Errorf("all tidal apis failed")
}
-238
View File
@@ -1,238 +0,0 @@
package backend
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
)
const tidalAltDownloadAPIBaseURL = "https://tidal.spotbye.qzz.io/get"
type TidalAltAPIResponse struct {
Title string `json:"title"`
Link string `json:"link"`
}
func buildTidalOutputPath(outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyTrackNumber, spotifyDiscNumber int, isrcOverride string, useFirstArtistOnly bool) (string, bool, error) {
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", false, fmt.Errorf("directory error: %w", err)
}
}
artistNameForFile := sanitizeFilename(spotifyArtistName)
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
if useFirstArtistOnly {
artistNameForFile = sanitizeFilename(GetFirstArtist(spotifyArtistName))
albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
}
trackTitleForFile := sanitizeFilename(spotifyTrackName)
albumTitleForFile := sanitizeFilename(spotifyAlbumName)
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrcOverride)
outputFilename := filepath.Join(outputDir, filename)
outputFilename, alreadyExists := ResolveOutputPathForDownload(outputFilename, GetRedownloadWithSuffixSetting())
return outputFilename, alreadyExists, nil
}
func finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useSingleGenre bool, embedGenre bool) {
trackTitle := spotifyTrackName
artistName := spotifyArtistName
albumTitle := spotifyAlbumName
type mbResult struct {
ISRC string
Metadata Metadata
}
metaChan := make(chan mbResult, 1)
if embedGenre && spotifyURL != "" {
go func() {
res := mbResult{}
var isrc string
parts := strings.Split(spotifyURL, "/")
if len(parts) > 0 {
sID := strings.Split(parts[len(parts)-1], "?")[0]
if sID != "" {
client := NewSongLinkClient()
if val, err := client.GetISRC(sID); err == nil {
isrc = val
}
}
}
res.ISRC = isrc
if isrc != "" {
if ShouldSkipMusicBrainzMetadataFetch() {
fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.")
} else {
fmt.Println("Fetching MusicBrainz metadata...")
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil {
res.Metadata = fetchedMeta
fmt.Println("✓ MusicBrainz metadata fetched")
} else {
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
}
}
}
metaChan <- res
}()
} else {
close(metaChan)
}
isrc := strings.TrimSpace(isrcOverride)
var mbMeta Metadata
if spotifyURL != "" {
result := <-metaChan
if isrc == "" {
isrc = result.ISRC
}
mbMeta = result.Metadata
}
upc := ""
if spotifyURL != "" {
if identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyURL); err == nil || identifiers.ISRC != "" || identifiers.UPC != "" {
if strings.TrimSpace(isrc) == "" && strings.TrimSpace(identifiers.ISRC) != "" {
isrc = strings.TrimSpace(identifiers.ISRC)
}
upc = strings.TrimSpace(identifiers.UPC)
}
}
fmt.Println("Adding metadata...")
coverPath := ""
if spotifyCoverURL != "" {
coverPath = outputFilename + ".cover.jpg"
coverClient := NewCoverClient()
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
coverPath = ""
} else {
defer os.Remove(coverPath)
fmt.Println("Spotify cover downloaded")
}
}
trackNumberToEmbed := spotifyTrackNumber
if trackNumberToEmbed == 0 {
trackNumberToEmbed = 1
}
metadata := Metadata{
Title: trackTitle,
Artist: artistName,
Album: albumTitle,
AlbumArtist: spotifyAlbumArtist,
Date: spotifyReleaseDate,
TrackNumber: trackNumberToEmbed,
TotalTracks: spotifyTotalTracks,
DiscNumber: spotifyDiscNumber,
TotalDiscs: spotifyTotalDiscs,
URL: spotifyURL,
Comment: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Composer: spotifyComposer,
Separator: metadataSeparator,
Description: "https://github.com/spotbye/SpotiFLAC",
ISRC: isrc,
UPC: upc,
Genre: mbMeta.Genre,
}
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
fmt.Printf("Tagging failed: %v\n", err)
} else {
fmt.Println("Metadata saved")
}
}
func (t *TidalDownloader) GetAltDownloadURLFromSpotify(spotifyTrackID string) (string, error) {
spotifyTrackID = strings.TrimSpace(spotifyTrackID)
if spotifyTrackID == "" {
return "", fmt.Errorf("spotify track ID is required")
}
apiURL := fmt.Sprintf("%s/%s", tidalAltDownloadAPIBaseURL, spotifyTrackID)
fmt.Printf("Tidal Alt. API URL: %s\n", apiURL)
req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create Tidal Alt. request: %w", err)
}
resp, err := t.client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to get Tidal Alt. download URL: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read Tidal Alt. response: %w", err)
}
if resp.StatusCode != http.StatusOK {
preview := strings.TrimSpace(string(body))
if len(preview) > 200 {
preview = preview[:200] + "..."
}
return "", fmt.Errorf("Tidal Alt. returned status %d: %s", resp.StatusCode, preview)
}
var payload TidalAltAPIResponse
if err := json.Unmarshal(body, &payload); err != nil {
return "", fmt.Errorf("failed to decode Tidal Alt. response: %w", err)
}
downloadURL := strings.TrimSpace(payload.Link)
if downloadURL == "" {
return "", fmt.Errorf("Tidal Alt. response did not include a download link")
}
fmt.Println("✓ Tidal Alt. download URL found")
return downloadURL, nil
}
func (t *TidalDownloader) DownloadAlt(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
spotifyTrackID = strings.TrimSpace(spotifyTrackID)
if spotifyTrackID == "" {
return "", fmt.Errorf("spotify track ID is required for Tidal Alt.")
}
outputFilename, alreadyExists, err := buildTidalOutputPath(outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyTrackNumber, spotifyDiscNumber, isrcOverride, useFirstArtistOnly)
if err != nil {
return "", err
}
if alreadyExists {
fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(mustFileSize(outputFilename))/(1024*1024))
return "EXISTS:" + outputFilename, nil
}
fmt.Printf("Using Tidal Alt. for Spotify track: %s\n", spotifyTrackID)
downloadURL, err := t.GetAltDownloadURLFromSpotify(spotifyTrackID)
if err != nil {
return outputFilename, err
}
fmt.Printf("Downloading to: %s\n", outputFilename)
if err := t.DownloadFile(downloadURL, outputFilename); err != nil {
cleanupTidalDownloadArtifacts(outputFilename)
return outputFilename, err
}
finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre)
fmt.Println("Done")
fmt.Println("✓ Downloaded successfully from Tidal Alt.")
return outputFilename, nil
}
-296
View File
@@ -1,296 +0,0 @@
package backend
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const (
tidalAPIListGistURL = "https://gist.githubusercontent.com/afkarxyz/2ce772b943321b9448b454f39403ce25/raw"
tidalAPIListCacheFile = "tidal-api-urls.json"
)
type tidalAPIListCache struct {
URLs []string `json:"urls"`
LastUsedURL string `json:"last_used_url,omitempty"`
UpdatedAt int64 `json:"updated_at_unix"`
Source string `json:"source,omitempty"`
}
var (
tidalAPIListMu sync.Mutex
tidalAPIListState *tidalAPIListCache
)
func loadTidalAPIListStateLocked() (*tidalAPIListCache, error) {
if tidalAPIListState != nil {
return cloneTidalAPIListState(tidalAPIListState), nil
}
appDir, err := EnsureAppDir()
if err != nil {
return nil, err
}
cachePath := filepath.Join(appDir, tidalAPIListCacheFile)
data, err := os.ReadFile(cachePath)
if err != nil {
if os.IsNotExist(err) {
state := &tidalAPIListCache{}
tidalAPIListState = cloneTidalAPIListState(state)
return cloneTidalAPIListState(state), nil
}
return nil, fmt.Errorf("failed to read tidal api cache: %w", err)
}
var state tidalAPIListCache
if err := json.Unmarshal(data, &state); err != nil {
return nil, fmt.Errorf("failed to parse tidal api cache: %w", err)
}
state.URLs = normalizeTidalAPIURLs(state.URLs)
tidalAPIListState = cloneTidalAPIListState(&state)
return cloneTidalAPIListState(&state), nil
}
func saveTidalAPIListStateLocked(state *tidalAPIListCache) error {
appDir, err := EnsureAppDir()
if err != nil {
return err
}
cachePath := filepath.Join(appDir, tidalAPIListCacheFile)
payload, err := json.MarshalIndent(state, "", " ")
if err != nil {
return fmt.Errorf("failed to encode tidal api cache: %w", err)
}
if err := os.WriteFile(cachePath, payload, 0o644); err != nil {
return fmt.Errorf("failed to write tidal api cache: %w", err)
}
tidalAPIListState = cloneTidalAPIListState(state)
return nil
}
func cloneTidalAPIListState(state *tidalAPIListCache) *tidalAPIListCache {
if state == nil {
return nil
}
return &tidalAPIListCache{
URLs: append([]string(nil), state.URLs...),
LastUsedURL: state.LastUsedURL,
UpdatedAt: state.UpdatedAt,
Source: state.Source,
}
}
func normalizeTidalAPIURLs(urls []string) []string {
seen := make(map[string]struct{}, len(urls))
normalized := make([]string, 0, len(urls))
for _, rawURL := range urls {
url := strings.TrimRight(strings.TrimSpace(rawURL), "/")
if url == "" {
continue
}
if _, exists := seen[url]; exists {
continue
}
seen[url] = struct{}{}
normalized = append(normalized, url)
}
return normalized
}
func fetchTidalAPIURLsFromGist() ([]string, error) {
client := &http.Client{Timeout: 12 * time.Second}
req, err := NewRequestWithDefaultHeaders(http.MethodGet, tidalAPIListGistURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create tidal api gist request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch tidal api gist: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
preview, _ := io.ReadAll(io.LimitReader(resp.Body, 200))
return nil, fmt.Errorf("tidal api gist returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(preview)))
}
var urls []string
if err := json.NewDecoder(resp.Body).Decode(&urls); err != nil {
return nil, fmt.Errorf("failed to decode tidal api gist: %w", err)
}
urls = normalizeTidalAPIURLs(urls)
if len(urls) == 0 {
return nil, fmt.Errorf("tidal api gist returned no valid urls")
}
return urls, nil
}
func PrimeTidalAPIList() error {
_, err := RefreshTidalAPIList(true)
if err != nil {
fmt.Printf("Warning: failed to refresh Tidal API list from gist: %v\n", err)
}
tidalAPIListMu.Lock()
defer tidalAPIListMu.Unlock()
state, loadErr := loadTidalAPIListStateLocked()
if loadErr != nil {
return loadErr
}
if len(state.URLs) == 0 {
return fmt.Errorf("tidal api cache is empty")
}
if state.UpdatedAt == 0 {
state.UpdatedAt = time.Now().Unix()
return saveTidalAPIListStateLocked(state)
}
return nil
}
func RefreshTidalAPIList(force bool) ([]string, error) {
tidalAPIListMu.Lock()
defer tidalAPIListMu.Unlock()
state, err := loadTidalAPIListStateLocked()
if err != nil {
state = &tidalAPIListCache{}
}
if !force && len(state.URLs) > 0 {
return append([]string(nil), state.URLs...), nil
}
urls, fetchErr := fetchTidalAPIURLsFromGist()
if fetchErr != nil {
if len(state.URLs) > 0 {
return append([]string(nil), state.URLs...), fetchErr
}
return nil, fetchErr
}
state.URLs = urls
state.UpdatedAt = time.Now().Unix()
state.Source = "gist"
if !containsString(state.URLs, state.LastUsedURL) {
state.LastUsedURL = ""
}
if err := saveTidalAPIListStateLocked(state); err != nil {
return append([]string(nil), state.URLs...), err
}
return append([]string(nil), state.URLs...), nil
}
func GetTidalAPIList() ([]string, error) {
tidalAPIListMu.Lock()
defer tidalAPIListMu.Unlock()
state, err := loadTidalAPIListStateLocked()
if err != nil {
return nil, err
}
if len(state.URLs) == 0 {
return nil, fmt.Errorf("no cached tidal api urls")
}
return append([]string(nil), state.URLs...), nil
}
func GetRotatedTidalAPIList() ([]string, error) {
tidalAPIListMu.Lock()
defer tidalAPIListMu.Unlock()
state, err := loadTidalAPIListStateLocked()
if err != nil {
return nil, err
}
urls := state.URLs
if len(urls) == 0 {
return nil, fmt.Errorf("no cached tidal api urls")
}
return rotateTidalAPIURLs(urls, state.LastUsedURL), nil
}
func RememberTidalAPIUsage(apiURL string) error {
tidalAPIListMu.Lock()
defer tidalAPIListMu.Unlock()
state, err := loadTidalAPIListStateLocked()
if err != nil {
return err
}
state.LastUsedURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
if state.UpdatedAt == 0 {
state.UpdatedAt = time.Now().Unix()
}
return saveTidalAPIListStateLocked(state)
}
func rotateTidalAPIURLs(urls []string, lastUsedURL string) []string {
normalized := normalizeTidalAPIURLs(urls)
if len(normalized) < 2 {
return normalized
}
lastUsedURL = strings.TrimRight(strings.TrimSpace(lastUsedURL), "/")
if lastUsedURL == "" {
return normalized
}
lastIndex := -1
for idx, candidate := range normalized {
if candidate == lastUsedURL {
lastIndex = idx
break
}
}
if lastIndex == -1 {
return normalized
}
rotated := make([]string, 0, len(normalized))
rotated = append(rotated, normalized[lastIndex+1:]...)
rotated = append(rotated, normalized[:lastIndex+1]...)
return rotated
}
func containsString(values []string, target string) bool {
target = strings.TrimRight(strings.TrimSpace(target), "/")
for _, value := range values {
if strings.TrimRight(strings.TrimSpace(value), "/") == target {
return true
}
}
return false
}
+2 -1
View File
@@ -20,6 +20,7 @@
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
@@ -55,4 +56,4 @@
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1"
}
}
}
+1 -1
View File
@@ -1 +1 @@
867c45db7982e126a7249d80210f23be
8864b4f7b7971b624d1ba25030f2db4e
+3
View File
@@ -32,6 +32,9 @@ importers:
'@radix-ui/react-select':
specifier: ^2.2.6
version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-slider':
specifier: ^1.3.6
version: 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-slot':
specifier: ^1.2.4
version: 1.2.4(@types/react@19.2.14)(react@19.2.4)
+17 -14
View File
@@ -24,15 +24,16 @@ import { AudioResamplerPage } from "@/components/AudioResamplerPage";
import { FileManagerPage } from "@/components/FileManagerPage";
import { SettingsPage } from "@/components/SettingsPage";
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
import { AboutPage } from "@/components/AboutPage";
import { OtherProjects } from "@/components/OtherProjects";
import { HistoryPage } from "@/components/HistoryPage";
import { SupportPage } from "@/components/SupportPage";
import type { HistoryItem } from "@/components/FetchHistory";
import { useDownload } from "@/hooks/useDownload";
import { useMetadata } from "@/hooks/useMetadata";
import { useLyrics } from "@/hooks/useLyrics";
import { useCover } from "@/hooks/useCover";
import { useAvailability } from "@/hooks/useAvailability";
import { ensureSpotiFLACNextStatusCheckStarted } from "@/lib/api-status";
import { ensureApiStatusCheckStarted } from "@/lib/api-status";
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
import { buildPlaylistFolderName } from "@/lib/playlist";
@@ -162,7 +163,7 @@ function App() {
if (savedSettings) {
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
applyFont(savedSettings.fontFamily);
applyFont(savedSettings.fontFamily, savedSettings.customFonts);
}
}, []);
useEffect(() => {
@@ -170,7 +171,7 @@ function App() {
const settings = await loadSettings();
applyThemeMode(settings.themeMode);
applyTheme(settings.theme);
applyFont(settings.fontFamily);
applyFont(settings.fontFamily, settings.customFonts);
if (!settings.downloadPath) {
const settingsWithDefaults = await getSettingsWithDefaults();
await saveSettings(settingsWithDefaults);
@@ -198,7 +199,7 @@ function App() {
};
mediaQuery.addEventListener("change", handleChange);
checkForUpdates();
ensureSpotiFLACNextStatusCheckStarted();
ensureApiStatusCheckStarted();
void loadHistory();
return () => {
mediaQuery.removeEventListener("change", handleChange);
@@ -446,7 +447,7 @@ function App() {
}
if ("album_info" in metadata.metadata) {
const { album_info, track_list } = metadata.metadata;
return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} downloadRemainingCount={download.downloadRemainingCount} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
setSpotifyUrl(pendingArtistUrl);
const artistUrl = await metadata.handleArtistClick(artist);
@@ -464,7 +465,7 @@ function App() {
const { playlist_info, track_list } = metadata.metadata;
const settings = getSettings();
const playlistFolderName = buildPlaylistFolderName(playlist_info.owner.name, playlist_info.owner.display_name, settings.playlistOwnerFolderName);
return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlistFolderName, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlistFolderName, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlistFolderName)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlistFolderName)} onDownloadAll={() => download.handleDownloadAll(track_list, playlistFolderName)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlistFolderName)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} downloadRemainingCount={download.downloadRemainingCount} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlistFolderName, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlistFolderName, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlistFolderName)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlistFolderName)} onDownloadAll={() => download.handleDownloadAll(track_list, playlistFolderName)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlistFolderName)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
setSpotifyUrl(pendingArtistUrl);
const artistUrl = await metadata.handleArtistClick(artist);
@@ -480,7 +481,7 @@ function App() {
}
if ("artist_info" in metadata.metadata) {
const { artist_info, album_list, track_list } = metadata.metadata;
return (<ArtistInfo artistInfo={artist_info} albumList={album_list} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
return (<ArtistInfo artistInfo={artist_info} albumList={album_list} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} downloadRemainingCount={download.downloadRemainingCount} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
setSpotifyUrl(pendingArtistUrl);
const artistUrl = await metadata.handleArtistClick(artist);
@@ -512,7 +513,7 @@ function App() {
const savedSettings = getSettings();
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
applyFont(savedSettings.fontFamily);
applyFont(savedSettings.fontFamily, savedSettings.customFonts);
if (pendingPageChange) {
setCurrentPage(pendingPageChange);
setPendingPageChange(null);
@@ -528,8 +529,10 @@ function App() {
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn}/>;
case "debug":
return <DebugLoggerPage />;
case "about":
return <AboutPage />;
case "projects":
return <OtherProjects />;
case "support":
return <SupportPage />;
case "history":
return <HistoryPage onHistorySelect={(cachedData) => {
metadata.loadFromCache(cachedData);
@@ -551,7 +554,7 @@ function App() {
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
<DialogContent className="sm:max-w-106.25 p-6 [&>button]:hidden">
<div className="absolute right-4 top-4">
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowAlbumDialog(false)}>
<X className="h-4 w-4"/>
@@ -624,7 +627,7 @@ function App() {
<Dialog open={showUnsavedChangesDialog} onOpenChange={setShowUnsavedChangesDialog}>
<DialogContent className="sm:max-w-[425px] [&>button]:hidden">
<DialogContent className="sm:max-w-106.25 [&>button]:hidden">
<DialogHeader>
<DialogTitle>Unsaved Changes</DialogTitle>
<DialogDescription>
@@ -671,7 +674,7 @@ function App() {
</Dialog>
<Dialog open={isFFmpegInstalled === false} onOpenChange={() => { }}>
<DialogContent className="max-w-[450px] [&>button]:hidden p-6 gap-5">
<DialogContent className="max-w-112.5 [&>button]:hidden p-6 gap-5">
<DialogHeader className="space-y-2">
<DialogTitle className="text-lg font-bold tracking-tight">
FFmpeg Required
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 749.3 227.1" fill="#FFFFFF">
<!-- Generator: Adobe Illustrator 29.3.1, SVG Export Plug-In . SVG Version: 2.1.0 Build 151) -->
<path d="M222.8,85.4c0-3.4,2.5-5.6,6.4-5.6h18.6c16.9,0,28.3,9.3,28.3,22.9s-11.3,23.3-28.3,23.3h-2.6c-6.5,0-9.8,3.4-9.8,8.8v15.2c0,4.3-2.5,7-6.3,7s-6.3-2.7-6.3-7v-64.6ZM235.4,104.7c0,6.8,3.5,10.1,10.1,10.1h1.6c9.3,0,16.1-3.8,16.1-12.1s-6.8-12.1-16.1-12.1h-1.6c-6.6,0-10.1,3.2-10.1,10.1v4.1ZM276.1,151.1c0,3.6,2.5,5.9,6.3,5.9s4.8-1.6,6.1-5l2.3-6.1c1.8-4.9,5.1-7.1,8.6-7.1h20.5c3.6,0,6.8,2.3,8.6,7.1l2.3,6.1c1.3,3.4,3.6,5,6.1,5,3.8,0,6.3-2.4,6.3-5.9s-.2-2.2-.6-3.4l-24.5-63.8c-1.5-3.9-5-5.8-8.3-5.8s-6.8,1.9-8.3,5.8l-24.5,63.8c-.4,1.2-.6,2.4-.6,3.4ZM300,122.1c0-1.2.3-2.3.9-3.9l4.6-12.9c.9-2.5,2.4-3.7,4.1-3.7s3.2,1.2,4.1,3.7l4.6,12.9c.5,1.6.9,2.7.9,3.9,0,3.2-1.8,5.5-6.7,5.5h-5.8c-4.9,0-6.7-2.3-6.7-5.5ZM339,85.6c0-3.5,2.5-5.8,6.5-5.8h49.7c4,0,6.5,2.4,6.5,5.8s-2.5,5.8-6.5,5.8h-8.3c-6.6,0-10.2,3.4-10.2,11v47.4c0,4.4-2.5,7.1-6.4,7.1s-6.4-2.7-6.4-7.1v-47.4c0-7.7-3.6-11-10.2-11h-8.3c-4,0-6.5-2.4-6.5-5.8ZM413.4,150c0,4.3,2.5,7,6.3,7s6.3-2.7,6.3-7v-17.2c0-4.9,2.8-6.9,6.3-6.9h.9c2.3,0,4.5,1.4,5.9,3.5l16.4,24.1c1.5,2.3,3.5,3.6,5.9,3.6s5.8-2.7,5.8-5.9-.4-2.7-1.4-4.1l-10.9-15.3c-1.3-1.8-1.8-3.4-1.8-4.6,0-2.7,2.4-4.6,5.2-6.7,5.1-3.8,10.6-8.8,10.6-18.3s-10.4-22.3-27.5-22.3h-21.7c-3.9,0-6.3,2.3-6.3,5.6v64.6ZM425.9,103.7v-3.2c0-7,3.7-9.9,9.3-9.9h5.4c9.3,0,15.2,3.5,15.2,11.5s-6.3,11.7-15.6,11.7h-5.1c-5.6,0-9.3-2.9-9.3-9.9ZM484.8,149.8v-64.4c0-3.4,2.4-5.6,6.3-5.6h40.9c3.9,0,6.3,2.3,6.3,5.6s-2.4,5.6-6.3,5.6h-25.8c-5.1,0-8.8,3-8.8,8.8v2.4c0,5.7,3.7,8.8,8.8,8.8h20c3.9,0,6.3,2.3,6.3,5.6s-2.4,5.6-6.3,5.6h-19.2c-5.1,0-9.5,3.1-9.5,9.5v3c0,6.4,4.4,9.5,9.5,9.5h25.1c3.9,0,6.3,2.3,6.3,5.6s-2.4,5.6-6.3,5.6h-40.9c-3.9,0-6.3-2.3-6.3-5.6ZM545.7,117.6c0-23.3,17.5-39.4,38-39.4s38,16.1,38,39.4-17.5,39.4-38,39.4-38-16.1-38-39.4ZM559.9,117.6c0,16.4,9.7,26.9,23.8,26.9s23.8-10.5,23.8-26.9-9.7-26.9-23.8-26.9-23.8,10.4-23.8,26.9ZM636.8,150c0,4.3,2.5,7,6.3,7s6.3-2.7,6.3-7v-33.1c0-4,2.4-5.9,4.9-5.9s3.6,1.1,4.8,3l20.8,34.7c2.8,4.8,5.4,8.3,10.7,8.3s8.8-3.7,8.8-9.6v-62.2c0-4.3-2.5-7-6.3-7s-6.3,2.7-6.3,7v33.1c0,4-2.4,5.9-4.9,5.9s-3.6-1.1-4.8-3l-20.8-34.7c-2.8-4.8-5.4-8.3-10.7-8.3s-8.8,3.7-8.8,9.6v62.2Z"/>
<path d="M169.2,87.5c0-16.7-13-30.3-28.2-35.2-18.9-6.1-43.8-5.2-61.9,3.3-21.9,10.3-28.7,32.9-29,55.5-.2,18.5,1.6,67.4,29.2,67.7,20.5.3,23.5-26.1,33-38.8,6.7-9,15.4-11.6,26.1-14.2,18.4-4.5,30.9-19,30.8-38.2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

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

After

Width:  |  Height:  |  Size: 735 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

+3 -2
View File
@@ -35,6 +35,7 @@ interface AlbumInfoProps {
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
downloadRemainingCount: number;
currentDownloadInfo: {
name: string;
artists: string;
@@ -77,7 +78,7 @@ interface AlbumInfoProps {
onTrackClick?: (track: TrackMetadata) => void;
onBack?: () => void;
}
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) {
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, downloadRemainingCount, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) {
const settings = getSettings();
const albumArtistNames = splitArtistNames(albumInfo.artists);
const artistSeparator = albumInfo.artists.includes(";") ? "; " : ", ";
@@ -270,7 +271,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
</TooltipContent>
</Tooltip>)}
</div>
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
{isDownloading && (<DownloadProgress progress={downloadProgress} remainingCount={downloadRemainingCount} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
</div>
</div>
</CardContent>
+23 -17
View File
@@ -1,14 +1,14 @@
import { Button } from "@/components/ui/button";
import { SearchCheck, CheckCircle2, XCircle, Loader2 } from "lucide-react";
import { TidalIcon, QobuzIcon, AmazonIcon, MusicBrainzIcon, AppleMusicIcon, DeezerIcon } from "./PlatformIcons";
import { PlugZap, CheckCircle2, Loader2, Wrench } from "lucide-react";
import { TidalIcon, QobuzIcon, AmazonIcon, AppleMusicIcon, DeezerIcon } from "./PlatformIcons";
import { useApiStatus } from "@/hooks/useApiStatus";
import { SPOTIFLAC_NEXT_SOURCES } from "@/lib/api-status";
function renderStatusIcon(status: "checking" | "online" | "offline" | "idle") {
function renderStatusIndicator(status: "checking" | "online" | "offline" | "idle") {
if (status === "online") {
return <CheckCircle2 className="h-5 w-5 text-emerald-500"/>;
}
if (status === "offline") {
return <XCircle className="h-5 w-5 text-destructive"/>;
return <Wrench className="h-4 w-4 text-amber-600 dark:text-amber-400"/>;
}
return null;
}
@@ -19,9 +19,6 @@ function renderPlatformIcon(type: string) {
if (type === "amazon") {
return <AmazonIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
}
if (type === "musicbrainz") {
return <MusicBrainzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
}
if (type === "deezer") {
return <DeezerIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
}
@@ -31,27 +28,30 @@ function renderPlatformIcon(type: string) {
return <QobuzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
}
export function ApiStatusTab() {
const { sources, statuses, nextStatuses, checkingSources, checkOne } = useApiStatus();
const { sources, statuses, nextStatuses, checkingSources, checkAllCurrent, checkAllNext } = useApiStatus();
const isCheckingCurrent = sources.some((source) => checkingSources[source.id] === true);
const isCheckingNext = SPOTIFLAC_NEXT_SOURCES.some((source) => nextStatuses[source.id] === "checking");
return (<div className="space-y-6">
<div className="space-y-4">
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Services</h3>
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC</h3>
<Button variant="outline" size="sm" onClick={() => void checkAllCurrent()} disabled={isCheckingCurrent} className="gap-2">
{isCheckingCurrent ? <Loader2 className="h-4 w-4 animate-spin"/> : <PlugZap className="h-4 w-4"/>}
Check
</Button>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{sources.map((source) => {
const status = statuses[source.id] || "idle";
const isChecking = checkingSources[source.id] === true;
return (<div key={source.id} className="space-y-4 p-4 border rounded-lg bg-card text-card-foreground shadow-sm">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
{renderPlatformIcon(source.type)}
<p className="font-medium leading-none">{source.name}</p>
</div>
<div className="flex items-center">{renderStatusIcon(status)}</div>
<div className="flex items-center">{renderStatusIndicator(status)}</div>
</div>
<Button variant="outline" size="sm" onClick={() => void checkOne(source.id)} disabled={isChecking} className="w-full gap-2">
{isChecking ? <Loader2 className="h-4 w-4 animate-spin"/> : <SearchCheck className="h-4 w-4"/>}
Check
</Button>
</div>);
})}
</div>
@@ -60,7 +60,13 @@ export function ApiStatusTab() {
<div className="border-t"/>
<div className="space-y-4">
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Next Services</h3>
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Next</h3>
<Button variant="outline" size="sm" onClick={() => void checkAllNext()} disabled={isCheckingNext} className="gap-2">
{isCheckingNext ? <Loader2 className="h-4 w-4 animate-spin"/> : <PlugZap className="h-4 w-4"/>}
Check
</Button>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5">
{SPOTIFLAC_NEXT_SOURCES.map((source) => {
@@ -70,7 +76,7 @@ export function ApiStatusTab() {
{renderPlatformIcon(source.id)}
<p className="font-medium leading-none">{source.name}</p>
</div>
<div className="flex items-center">{renderStatusIcon(status)}</div>
<div className="flex items-center">{renderStatusIndicator(status)}</div>
</div>);
})}
</div>
+5 -4
View File
@@ -48,6 +48,7 @@ interface ArtistInfoProps {
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
downloadRemainingCount: number;
currentDownloadInfo: {
name: string;
artists: string;
@@ -95,7 +96,7 @@ interface ArtistInfoProps {
onTrackClick?: (track: TrackMetadata) => void;
onBack?: () => void;
}
export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onAlbumClick, onArtistClick, onPageChange, onTrackClick, onBack, }: ArtistInfoProps) {
export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, downloadRemainingCount, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onAlbumClick, onArtistClick, onPageChange, onTrackClick, onBack, }: ArtistInfoProps) {
const [downloadingHeader, setDownloadingHeader] = useState(false);
const [downloadingAvatar, setDownloadingAvatar] = useState(false);
const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null);
@@ -325,7 +326,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
{artistInfo.header ? (<>
<div className="relative w-full h-64 bg-cover bg-center">
<div className="absolute inset-0 bg-cover bg-center" style={{ backgroundImage: `url(${artistInfo.header})` }}/>
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent"/>
<div className="absolute inset-0 bg-linear-to-t from-black via-black/50 to-transparent"/>
{onBack && (<div className="absolute top-4 right-4 z-10">
<Button variant="ghost" size="icon" onClick={onBack} className="text-white hover:bg-white/20 hover:text-white">
<XCircle className="h-5 w-5"/>
@@ -563,7 +564,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
Filter Albums
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px] h-[80vh] flex flex-col">
<DialogContent className="sm:max-w-125 h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Select Albums</DialogTitle>
</DialogHeader>
@@ -634,7 +635,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
</Tooltip>)}
</div>
</div>
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
{isDownloading && (<DownloadProgress progress={downloadProgress} remainingCount={downloadRemainingCount} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
<SearchAndSort searchQuery={searchQuery} sortBy={sortBy} onSearchChange={onSearchChange} onSortChange={onSortChange}/>
<TrackList tracks={trackList} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={downloadedTracks} failedTracks={failedTracks} skippedTracks={skippedTracks} downloadingTrack={downloadingTrack} isDownloading={isDownloading} currentPage={currentPage} itemsPerPage={itemsPerPage} showCheckboxes={true} hideAlbumColumn={false} folderName={artistInfo.name} isArtistDiscography={true} downloadedLyrics={downloadedLyrics} failedLyrics={failedLyrics} skippedLyrics={skippedLyrics} downloadingLyricsTrack={downloadingLyricsTrack} checkingAvailabilityTrack={checkingAvailabilityTrack} availabilityMap={availabilityMap} onToggleTrack={onToggleTrack} onToggleSelectAll={onToggleSelectAll} onDownloadTrack={onDownloadTrack} onDownloadLyrics={onDownloadLyrics} onDownloadCover={onDownloadCover} downloadedCovers={downloadedCovers} failedCovers={failedCovers} skippedCovers={skippedCovers} downloadingCoverTrack={downloadingCoverTrack} onCheckAvailability={onCheckAvailability} onPageChange={onPageChange} onAlbumClick={onAlbumClick} onArtistClick={onArtistClick} onTrackClick={onTrackClick}/>
</div>)}
+5 -2
View File
@@ -3,14 +3,17 @@ import { Progress } from "@/components/ui/progress";
import { StopCircle } from "lucide-react";
interface DownloadProgressProps {
progress: number;
remainingCount?: number;
currentTrack: {
name: string;
artists: string;
} | null;
onStop: () => void;
}
export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) {
export function DownloadProgress({ progress, remainingCount = 0, currentTrack, onStop }: DownloadProgressProps) {
const clampedProgress = Math.min(100, Math.max(0, progress));
const safeRemainingCount = Math.max(0, remainingCount);
const remainingLabel = `${safeRemainingCount.toLocaleString()} ${safeRemainingCount === 1 ? "track" : "tracks"} left`;
return (<div className="w-full space-y-2 mt-4">
<div className="flex items-center gap-2">
<Progress value={clampedProgress} className="h-2 flex-1"/>
@@ -20,7 +23,7 @@ export function DownloadProgress({ progress, currentTrack, onStop }: DownloadPro
</Button>
</div>
<p className="text-xs text-muted-foreground">
{clampedProgress}% -{" "}
{clampedProgress}% {remainingLabel} -{" "}
{currentTrack
? `${currentTrack.name} - ${currentTrack.artists}`
: "Preparing download..."}
+61 -15
View File
@@ -9,7 +9,8 @@ import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, Pagi
import { GetDownloadHistory, ClearDownloadHistory, GetPreviewURL, GetFetchHistory, DeleteDownloadHistoryItem, DeleteFetchHistoryItem, ClearFetchHistoryByType } from "../../wailsjs/go/main/App";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { openExternal } from "@/lib/utils";
import { SPOTIFY_PREVIEW_VOLUME } from "@/lib/preview";
import { getPreviewVolume } from "@/lib/preview";
import { createPreviewPlayback, type PreviewPlayback } from "@/lib/preview-player";
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
const formatDate = (timestamp: number) => {
const date = new Date(timestamp * 1000);
@@ -21,6 +22,37 @@ const formatDate = (timestamp: number) => {
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
const getHistoryFormatLabel = (item: DownloadHistoryItem) => {
const normalizedPath = (item.path || "").trim().toLowerCase();
if (normalizedPath.endsWith(".flac"))
return "FLAC";
if (normalizedPath.endsWith(".mp3"))
return "MP3";
if (normalizedPath.endsWith(".m4a"))
return "M4A";
const normalizedFormat = (item.format || "").trim().toLowerCase();
switch (normalizedFormat) {
case "hi_res":
case "hi_res_lossless":
case "lossless":
case "flac":
case "6":
case "7":
case "27":
return "FLAC";
case "alac":
case "apple":
case "atmos":
case "m4a":
case "m4a-aac":
case "m4a-alac":
return "M4A";
case "mp3":
return "MP3";
default:
return (item.format || "-").toUpperCase();
}
};
interface DownloadHistoryItem {
id: string;
spotify_id: string;
@@ -57,7 +89,7 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
const [downloadSortBy, setDownloadSortBy] = useState("default");
const [downloadCurrentPage, setDownloadCurrentPage] = useState(1);
const [playingPreviewId, setPlayingPreviewId] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const playbackRef = useRef<PreviewPlayback | null>(null);
const [fetchHistory, setFetchHistory] = useState<FetchHistoryItem[]>([]);
const [filteredFetchHistory, setFilteredFetchHistory] = useState<FetchHistoryItem[]>([]);
const [activeFetchTab, setActiveFetchTab] = useState("track");
@@ -122,9 +154,8 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
}, [activeTab]);
useEffect(() => {
return () => {
if (audioRef.current) {
audioRef.current.pause();
}
playbackRef.current?.destroy();
playbackRef.current = null;
};
}, []);
useEffect(() => {
@@ -180,20 +211,35 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
}, [fetchSearchQuery, activeFetchTab]);
const handlePreview = async (id: string, spotifyId: string) => {
if (playingPreviewId === id) {
audioRef.current?.pause();
playbackRef.current?.destroy();
playbackRef.current = null;
setPlayingPreviewId(null);
return;
}
if (audioRef.current) {
audioRef.current.pause();
if (playbackRef.current) {
playbackRef.current.destroy();
playbackRef.current = null;
}
try {
const url = await GetPreviewURL(spotifyId);
if (url) {
const audio = new Audio(url);
audioRef.current = audio;
audio.volume = SPOTIFY_PREVIEW_VOLUME;
audio.onended = () => setPlayingPreviewId(null);
const playback = await createPreviewPlayback(url, getPreviewVolume());
const audio = playback.audio;
playbackRef.current = playback;
audio.onended = () => {
setPlayingPreviewId(null);
if (playbackRef.current?.audio === audio) {
playbackRef.current.destroy();
playbackRef.current = null;
}
};
audio.onerror = () => {
setPlayingPreviewId(null);
if (playbackRef.current?.audio === audio) {
playbackRef.current.destroy();
playbackRef.current = null;
}
};
audio.play();
setPlayingPreviewId(id);
}
@@ -271,7 +317,7 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
<Input placeholder="Search downloads..." value={downloadSearchQuery} onChange={(e) => setDownloadSearchQuery(e.target.value)} className="pl-8 h-9"/>
</div>
<Select value={downloadSortBy} onValueChange={setDownloadSortBy}>
<SelectTrigger className="w-[180px] h-9">
<SelectTrigger className="w-45 h-9">
<ArrowUpDown className="mr-2 h-4 w-4"/>
<SelectValue placeholder="Sort by"/>
</SelectTrigger>
@@ -329,10 +375,10 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
<div className="truncate">{item.album}</div>
</td>
<td className="p-3 align-middle text-left hidden lg:table-cell">
<td className="p-3 align-middle text-left hidden lg:table-cell">
<div className="flex flex-col items-start gap-1">
<span className="text-xs font-bold text-foreground">
{['HI_RES_LOSSLESS', 'LOSSLESS', 'flac', '6', '7', '27'].includes(item.format?.toLowerCase() || '') ? 'FLAC' : item.format?.toUpperCase()}
{getHistoryFormatLabel(item)}
</span>
{item.quality && <span className="text-[11px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>}
</div>
@@ -1,24 +1,18 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { useEffect, useState } from "react";
import { openExternal } from "@/lib/utils";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
import { Star, GitFork, Clock, Download, Blocks, Heart, Copy, CircleCheck, Info } from "lucide-react";
import { Star, GitFork, Clock, Download, Info } from "lucide-react";
import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
import XIcon from "@/assets/x.webp";
import XProIcon from "@/assets/x-pro.webp";
import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
import SpotiFLACNextIcon from "@/assets/icons/next.svg";
import KofiLogo from "@/assets/ko-fi.gif";
import KofiSvg from "@/assets/kofi_symbol.svg";
import UsdtBarcode from "@/assets/usdt.jpg";
import { langColors } from "@/assets/github-lang-colors";
const browserExtensionItems = [
{ icon: AudioTTSProIcon, label: "AudioTTS Pro", alt: "AudioTTS Pro" },
{ icon: ChatGPTTTSIcon, label: "ChatGPT TTS", alt: "ChatGPT TTS" },
{ icon: XIcon, label: "Twitter/X Media Batch Downloader", alt: "Twitter/X Media Batch Downloader" },
{ icon: XProIcon, label: "Twitter/X Media Batch Downloader Pro", alt: "Twitter/X Media Batch Downloader Pro" },
];
const projectCardClass = "cursor-pointer gap-3 py-5 transition-colors hover:bg-muted/50 dark:hover:bg-accent/50";
const projectCardHeaderClass = "px-5 gap-1.5";
@@ -26,10 +20,8 @@ const projectCardContentClass = "px-5";
const projectBodyClass = "text-[13px] leading-snug";
const releaseMetaClass = "text-xs text-muted-foreground whitespace-nowrap";
const releaseVersionClass = "text-xs bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold whitespace-nowrap";
export function AboutPage() {
const [activeTab, setActiveTab] = useState<"projects" | "support">("projects");
export function OtherProjects() {
const [repoStats, setRepoStats] = useState<Record<string, any>>({});
const [copiedUsdt, setCopiedUsdt] = useState(false);
useEffect(() => {
const fetchRepoStats = async () => {
const CACHE_KEY = "github_repo_stats_v4";
@@ -181,24 +173,10 @@ export function AboutPage() {
};
return (<div className="flex flex-col space-y-3">
<div className="flex items-center justify-between shrink-0">
<h2 className="text-2xl font-bold tracking-tight">About</h2>
<h2 className="text-2xl font-bold tracking-tight">Other Projects</h2>
</div>
<div className="flex gap-2 border-b shrink-0">
<Button variant={activeTab === "projects" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("projects")} className="rounded-b-none">
<Blocks className="h-4 w-4"/>
Other Projects
</Button>
<Button variant={activeTab === "support" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("support")} className="rounded-b-none">
<Heart className="h-4 w-4"/>
Support Me
</Button>
</div>
<div className="flex-1 min-h-0">
{activeTab === "projects" && (<div className="pr-1.5">
<div className="flex-1 min-h-0 pr-1.5">
<div className="grid gap-2 grid-cols-3">
<Card className={projectCardClass} onClick={() => openExternal("https://github.com/spotbye/SpotiFLAC-Next")}>
<CardHeader className={projectCardHeaderClass}>
@@ -249,7 +227,7 @@ export function AboutPage() {
Note
</div>
<p className="text-xs leading-snug text-sky-700 dark:text-sky-300">
This project was created as a thank-you to everyone who has supported SpotiFLAC on Ko-fi.
This project released as a token of appreciation for those who have supported SpotiFLAC through Ko-fi, Patreon, or crypto. Its not a paid product, but its shared privately through a supporter-only post.
</p>
</div>
</CardContent>)}
@@ -313,12 +291,12 @@ export function AboutPage() {
</CardContent>)}
</Card>
<div className="flex h-full flex-col gap-1.5">
<Card className={`${projectCardClass} flex-1`} onClick={() => openExternal("https://exyezed.qzz.io/")}>
<Card className={`${projectCardClass} flex-1`} onClick={() => openExternal("https://exyezed.fyi/")}>
<CardHeader className={projectCardHeaderClass}>
<CardTitle className="leading-tight">Browser Extensions & Scripts</CardTitle>
<CardDescription className="flex flex-col gap-2.5 pt-1.5">
{browserExtensionItems.map((item) => (<div key={item.alt} className="flex items-center gap-2.5">
<img src={item.icon} className="h-[22px] w-[22px] rounded-sm shadow-sm" alt={item.alt}/>
<img src={item.icon} className="h-5.5 w-5.5 rounded-sm shadow-sm" alt={item.alt}/>
<span className={`${projectBodyClass} text-muted-foreground`}>
{item.label}
</span>
@@ -339,55 +317,6 @@ export function AboutPage() {
</Card>
</div>
</div>
</div>)}
{activeTab === "support" && (<div className="flex flex-col items-center justify-center p-4 space-y-6">
<div className="flex flex-col md:flex-row w-full max-w-3xl bg-card rounded-xl border shadow-sm">
<div className="flex-1 p-6 flex flex-col items-center justify-between border-b md:border-b-0 md:border-r space-y-6">
<div className="flex flex-col items-center space-y-4">
<div className="h-32 flex items-center justify-center w-full relative">
<img src={KofiLogo} className="w-72 absolute pointer-events-none" alt="Ko-fi"/>
</div>
<h4 className="font-semibold text-foreground">Support via Ko-fi</h4>
<p className="text-sm text-muted-foreground text-center px-4">
Enjoying the project? You can support ongoing development by buying me a coffee.
</p>
</div>
<Button className="h-10 w-full text-sm font-semibold text-white gap-2 group bg-[#72a4f2] hover:bg-[#5f8cd6]" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
<img src={KofiSvg} className="w-5 h-5 shrink-0" alt="" aria-hidden="true"/>
Support me on Ko-fi
</Button>
</div>
<div className="flex-1 p-6 flex flex-col items-center justify-between space-y-6">
<div className="flex flex-col items-center space-y-4 w-full">
<div className="h-32 flex items-center justify-center">
<div className="p-2 bg-white rounded-xl shadow-sm border">
<img src={UsdtBarcode} className="w-24 h-24 object-contain" alt="USDT Barcode"/>
</div>
</div>
<h4 className="font-semibold text-foreground">USDT (TRC20)</h4>
<p className="text-sm text-muted-foreground text-center px-4">
Crypto donations are also accepted. Scan the QR code or copy the address.
</p>
</div>
<div className="flex items-center gap-2 bg-muted/50 pl-3 pr-1.5 py-1.5 rounded-lg border w-full justify-between h-10">
<code className="text-xs font-mono text-muted-foreground truncate" title="THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs">
THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs
</code>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 hover:bg-background" onClick={() => {
navigator.clipboard.writeText("THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs");
setCopiedUsdt(true);
setTimeout(() => setCopiedUsdt(false), 500);
}}>
{copiedUsdt ? <CircleCheck className="h-3.5 w-3.5 text-green-500"/> : <Copy className="h-3.5 w-3.5"/>}
</Button>
</div>
</div>
</div>
</div>)}
</div>
</div>);
}
+3 -2
View File
@@ -41,6 +41,7 @@ interface PlaylistInfoProps {
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
downloadRemainingCount: number;
currentDownloadInfo: {
name: string;
artists: string;
@@ -88,7 +89,7 @@ interface PlaylistInfoProps {
onTrackClick: (track: TrackMetadata) => void;
onBack?: () => void;
}
export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, onBack, }: PlaylistInfoProps) {
export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, downloadRemainingCount, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, onBack, }: PlaylistInfoProps) {
const settings = getSettings();
const playlistName = playlistInfo.owner.name;
const playlistFolderName = buildPlaylistFolderName(playlistName, playlistInfo.owner.display_name, settings.playlistOwnerFolderName);
@@ -235,7 +236,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
</TooltipContent>
</Tooltip>)}
</div>
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
{isDownloading && (<DownloadProgress progress={downloadProgress} remainingCount={downloadRemainingCount} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
</div>
</div>
</CardContent>
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -6,18 +6,18 @@ import { ActivityIcon, type ActivityIconHandle } from "@/components/ui/activity"
import { TerminalIcon } from "@/components/ui/terminal";
import { FileMusicIcon, type FileMusicIconHandle } from "@/components/ui/file-music";
import { FilePenIcon, type FilePenIconHandle } from "@/components/ui/file-pen";
import { BugReportIcon } from "@/components/ui/bug-report-icon";
import { CoffeeIcon } from "@/components/ui/coffee";
import { BadgeAlertIcon } from "@/components/ui/badge-alert";
import { GithubIcon } from "@/components/ui/github";
import { BlocksIcon } from "@/components/ui/blocks-icon";
import { AudioLinesIcon, type AudioLinesIconHandle } from "@/components/ui/audio-lines";
import { ToolCaseIcon } from "@/components/ui/tool-case";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { openExternal } from "@/lib/utils";
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "about" | "history";
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "projects" | "support" | "history";
interface SidebarProps {
currentPage: PageType;
onPageChange: (page: PageType) => void;
@@ -100,7 +100,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
<DropdownMenuTrigger asChild>
<TooltipTrigger asChild>
<Button variant={["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`}>
<BlocksIcon size={20} loop={true}/>
<ToolCaseIcon size={20}/>
</Button>
</TooltipTrigger>
</DropdownMenuTrigger>
@@ -134,7 +134,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => setIsIssuesDialogOpen(true)}>
<GithubIcon size={20}/>
<BugReportIcon size={20} loop={true}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
@@ -176,23 +176,23 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant={currentPage === "about" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "about" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("about")}>
<BadgeAlertIcon size={20}/>
<Button variant={currentPage === "projects" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "projects" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("projects")}>
<BlocksIcon size={20} loop={true}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>About</p>
<p>Other Projects</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
<Button variant={currentPage === "support" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "support" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("support")}>
<CoffeeIcon size={20} loop={true}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Support me on Ko-fi</p>
<p>Support Me</p>
</TooltipContent>
</Tooltip>
</div>
+97
View File
@@ -0,0 +1,97 @@
import { useState } from "react";
import { CircleCheck, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { openExternal } from "@/lib/utils";
import KofiLogo from "@/assets/ko-fi.gif";
import KofiSvg from "@/assets/kofi_symbol.svg";
import PatreonLogo from "@/assets/patreon.svg";
import PatreonSymbol from "@/assets/patreon_symbol.svg";
import UsdtBarcode from "@/assets/usdt.jpg";
export function SupportPage() {
const [copiedUsdt, setCopiedUsdt] = useState(false);
const [copiedEmail, setCopiedEmail] = useState(false);
return (<div className="flex flex-col space-y-3">
<div className="flex items-center justify-between shrink-0">
<h2 className="text-2xl font-bold tracking-tight">Support Me</h2>
</div>
<div className="flex flex-col items-center justify-center p-4">
<div className="grid w-full max-w-5xl overflow-hidden rounded-xl border bg-card shadow-sm md:grid-cols-3">
<div className="flex min-h-[22rem] flex-col items-center justify-between space-y-6 border-b p-6 md:border-b-0 md:border-r">
<div className="flex flex-col items-center space-y-4">
<div className="h-32 flex items-center justify-center w-full relative">
<img src={KofiLogo} className="w-72 absolute pointer-events-none" alt="Ko-fi"/>
</div>
<h4 className="font-semibold text-foreground">Support via Ko-fi</h4>
<p className="text-sm text-muted-foreground text-center px-4">
Buy me a coffee to help keep development going.
</p>
</div>
<Button className="h-10 w-full gap-2 bg-[#72a4f2] text-sm font-semibold text-white hover:bg-[#5f8cd6]" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
<img src={KofiSvg} className="h-6 w-6 shrink-0" alt="" aria-hidden="true"/>
Support me on Ko-fi
</Button>
</div>
<div className="flex min-h-[22rem] flex-col items-center justify-between space-y-6 border-b p-6 md:border-b-0 md:border-r">
<div className="flex flex-col items-center space-y-4 w-full">
<div className="h-32 flex items-center justify-center w-full px-4">
<img src={PatreonLogo} className="w-56 max-w-full brightness-0 dark:brightness-100" alt="Patreon"/>
</div>
<h4 className="font-semibold text-foreground">Support via Patreon</h4>
<p className="text-sm text-muted-foreground text-center px-4">
Join on Patreon to help fund the project and follow updates.
</p>
</div>
<Button className="h-10 w-full gap-2 bg-[#ff424d] text-sm font-semibold text-white hover:bg-[#e63945]" onClick={() => openExternal("https://www.patreon.com/cw/afkarxyz")}>
<img src={PatreonSymbol} className="h-5 w-5 shrink-0" alt="" aria-hidden="true"/>
Support me on Patreon
</Button>
</div>
<div className="flex min-h-[22rem] flex-col items-center justify-between space-y-6 p-6">
<div className="flex flex-col items-center space-y-4 w-full">
<div className="h-32 flex items-center justify-center">
<div className="rounded-xl border bg-white p-2 shadow-sm">
<img src={UsdtBarcode} className="h-24 w-24 object-contain" alt="USDT Barcode"/>
</div>
</div>
<h4 className="font-semibold text-foreground">USDT (TRC20)</h4>
<p className="text-sm text-muted-foreground text-center px-4">
Prefer crypto? Use the QR code or wallet address below.
</p>
</div>
<div className="flex h-10 w-full items-center justify-between gap-2 rounded-lg border bg-muted/50 py-1.5 pl-3 pr-1.5">
<code className="truncate text-xs font-mono text-muted-foreground" title="THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs">
THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs
</code>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 hover:bg-background" onClick={() => {
navigator.clipboard.writeText("THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs");
setCopiedUsdt(true);
setTimeout(() => setCopiedUsdt(false), 500);
}}>
{copiedUsdt ? <CircleCheck className="h-3.5 w-3.5 text-green-500"/> : <Copy className="h-3.5 w-3.5"/>}
</Button>
</div>
</div>
</div>
<div className="mt-4 w-full max-w-5xl rounded-xl border bg-muted/30 px-4 py-3 text-center text-sm text-muted-foreground">
If you have any questions or need help with donating, feel free to reach out via{" "}
<button type="button" className="font-medium text-foreground underline-offset-4 hover:underline" onClick={() => openExternal("https://t.me/afkarxyz")}>
Telegram
</button>{" "}
or{" "}
<button type="button" className="font-medium text-foreground underline-offset-4 hover:underline" onClick={() => {
navigator.clipboard.writeText("hi@afkarxyz.fyi");
setCopiedEmail(true);
setTimeout(() => setCopiedEmail(false), 500);
}}>
{copiedEmail ? "copied" : "hi@afkarxyz.fyi"}
</button>
.
</div>
</div>
</div>);
}
+47 -3
View File
@@ -1,6 +1,9 @@
import { X, Minus, Maximize, SlidersHorizontal, Globe, Eye, EyeOff } from "lucide-react";
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
import { Menubar, MenubarContent, MenubarMenu, MenubarItem, MenubarTrigger, MenubarLabel, MenubarSeparator } from "@/components/ui/menubar";
import { Slider } from "@/components/ui/slider";
import { getSettings, updateSettings } from "@/lib/settings";
import { PREVIEW_VOLUME_CHANGED_EVENT } from "@/lib/preview";
import { fetchCurrentIPInfo } from "@/lib/api";
import type { CurrentIPInfo } from "@/types/api";
import { openExternal } from "@/lib/utils";
@@ -24,7 +27,12 @@ const SPOTIFY_BLOCKED_COUNTRY_CODES = new Set([
"TM",
"YE",
]);
interface SettingsUpdatedDetail {
previewVolume?: number;
}
export function TitleBar() {
const initialSettings = getSettings();
const [previewVolume, setPreviewVolume] = useState(initialSettings.previewVolume ?? 100);
const [currentIPInfo, setCurrentIPInfo] = useState<CurrentIPInfo | null>(null);
const [isLoadingCurrentIPInfo, setIsLoadingCurrentIPInfo] = useState(false);
const [currentIPInfoError, setCurrentIPInfoError] = useState("");
@@ -33,6 +41,16 @@ export function TitleBar() {
useEffect(() => {
currentIPInfoRef.current = currentIPInfo;
}, [currentIPInfo]);
useEffect(() => {
const handleSettingsUpdate = (event: Event) => {
const updatedSettings = (event as CustomEvent<SettingsUpdatedDetail>).detail;
if (updatedSettings && typeof updatedSettings.previewVolume === "number") {
setPreviewVolume(updatedSettings.previewVolume);
}
};
window.addEventListener("settingsUpdated", handleSettingsUpdate);
return () => window.removeEventListener("settingsUpdated", handleSettingsUpdate);
}, []);
const loadCurrentIPInfo = async (options?: {
silent?: boolean;
}) => {
@@ -88,6 +106,22 @@ export function TitleBar() {
const handleClose = () => {
Quit();
};
const handlePreviewVolumeChange = (value: number[]) => {
const nextValue = value[0];
if (typeof nextValue !== "number" || Number.isNaN(nextValue)) {
return;
}
setPreviewVolume(nextValue);
window.dispatchEvent(new CustomEvent(PREVIEW_VOLUME_CHANGED_EVENT, { detail: nextValue }));
};
const handlePreviewVolumeCommit = (value: number[]) => {
const nextValue = value[0];
if (typeof nextValue !== "number" || Number.isNaN(nextValue)) {
return;
}
setPreviewVolume(nextValue);
void updateSettings({ previewVolume: nextValue });
};
const detectedCountryCode = currentIPInfo?.country_code?.toUpperCase() || "";
const detectedFlagPath = detectedCountryCode ? `/assets/flags/${detectedCountryCode.toLowerCase()}.svg` : "";
const isSpotifyBlockedCountry = detectedCountryCode !== "" && SPOTIFY_BLOCKED_COUNTRY_CODES.has(detectedCountryCode);
@@ -102,7 +136,17 @@ export function TitleBar() {
<MenubarTrigger className="cursor-pointer w-8 h-7 p-0 flex items-center justify-center hover:bg-muted transition-colors rounded data-[state=open]:bg-muted">
<SlidersHorizontal className="w-3.5 h-3.5"/>
</MenubarTrigger>
<MenubarContent align="end" className="min-w-[280px]">
<MenubarContent align="end" className="min-w-70">
<div className="px-2 py-1.5 space-y-2">
<div className="flex items-center justify-between gap-3">
<MenubarLabel className="p-0">Preview Volume</MenubarLabel>
<span className="text-xs font-medium text-muted-foreground tabular-nums">
{previewVolume}%
</span>
</div>
<Slider value={[previewVolume]} min={0} max={100} step={5} onValueChange={handlePreviewVolumeChange} onValueCommit={handlePreviewVolumeCommit} aria-label="Preview volume"/>
</div>
<MenubarSeparator />
<div className="flex items-center gap-1.5 px-2 py-1.5">
<MenubarLabel className="p-0">Network</MenubarLabel>
{isSpotifyBlockedCountry && (<span className="text-xs font-medium text-destructive">
@@ -112,7 +156,7 @@ export function TitleBar() {
<div className="px-2 py-1.5 space-y-1">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
{detectedFlagPath ? (<img src={detectedFlagPath} alt={detectedCountryCode} className="h-3.5 w-[18px] rounded-[2px] border object-cover bg-muted"/>) : (<Globe className="w-4 h-4 opacity-70"/>)}
{detectedFlagPath ? (<img src={detectedFlagPath} alt={detectedCountryCode} className="h-3.5 w-4.5 rounded-[2px] border object-cover bg-muted"/>) : (<Globe className="w-4 h-4 opacity-70"/>)}
<span className="font-mono text-xs truncate">
{isLoadingCurrentIPInfo
? "Detecting..."
@@ -132,7 +176,7 @@ export function TitleBar() {
</div>)}
</div>
<MenubarSeparator />
<MenubarItem onClick={() => openExternal("https://afkarxyz.qzz.io")} className="gap-2">
<MenubarItem onClick={() => openExternal("https://afkarxyz.fyi")} className="gap-2">
<Globe className="w-4 h-4 opacity-70"/>
<span>Website</span>
</MenubarItem>
@@ -1,61 +0,0 @@
"use client";
import type { Variants } from "motion/react";
import { motion, useAnimation } from "motion/react";
import type { HTMLAttributes } from "react";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { cn } from "@/lib/utils";
export interface BadgeAlertIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface BadgeAlertIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const ICON_VARIANTS: Variants = {
normal: { scale: 1, rotate: 0 },
animate: {
scale: [1, 1.1, 1.1, 1.1, 1],
rotate: [0, -3, 3, -2, 2, 0],
transition: {
duration: 0.5,
times: [0, 0.2, 0.4, 0.6, 1],
ease: "easeInOut",
},
},
};
const BadgeAlertIcon = forwardRef<BadgeAlertIconHandle, BadgeAlertIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start("animate"),
stopAnimation: () => controls.start("normal"),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseEnter?.(e);
}
else {
controls.start("animate");
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseLeave?.(e);
}
else {
controls.start("normal");
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<motion.svg animate={controls} fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" variants={ICON_VARIANTS} viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
<path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/>
<line x1="12" x2="12" y1="8" y2="12"/>
<line x1="12" x2="12.01" y1="16" y2="16"/>
</motion.svg>
</div>);
});
BadgeAlertIcon.displayName = "BadgeAlertIcon";
export { BadgeAlertIcon };
@@ -0,0 +1,132 @@
"use client";
import type { Transition, Variants } from "motion/react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState, type HTMLAttributes } from "react";
import { cn } from "@/lib/utils";
type ReportIconMode = "bug" | "bulb";
interface BugReportIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
loop?: boolean;
}
const LOOP_INTERVAL_MS = 2200;
const GROUP_VARIANTS: Variants = {
hidden: {
opacity: 0,
},
visible: {
opacity: 1,
transition: {
duration: 0.2,
ease: [0, 0, 0.2, 1],
},
},
exit: {
opacity: 0,
transition: {
duration: 0.18,
ease: [0.4, 0, 1, 1],
},
},
};
const DRAW_VARIANTS: Variants = {
hidden: {
pathLength: 0,
opacity: 0,
},
visible: {
pathLength: 1,
opacity: 1,
},
exit: {
pathLength: 1,
opacity: 0,
},
};
function createDrawTransition(delay = 0, duration = 0.36): Transition {
return {
duration,
delay,
ease: [0.4, 0, 0.2, 1],
opacity: { delay },
};
}
function BugPaths() {
return (<>
<motion.path d="m8 2 1.88 1.88" transition={createDrawTransition(0)} variants={DRAW_VARIANTS}/>
<motion.path d="M14.12 3.88 16 2" transition={createDrawTransition(0.04)} variants={DRAW_VARIANTS}/>
<motion.path d="M9 7.13V6a3 3 0 1 1 6 0v1.13" transition={createDrawTransition(0.08)} variants={DRAW_VARIANTS}/>
<motion.path d="M6.53 9A4 4 0 0 1 3 5" transition={createDrawTransition(0.14)} variants={DRAW_VARIANTS}/>
<motion.path d="M17.47 9A4 4 0 0 0 21 5" transition={createDrawTransition(0.18)} variants={DRAW_VARIANTS}/>
<motion.path d="M12 20v-9" transition={createDrawTransition(0.24)} variants={DRAW_VARIANTS}/>
<motion.path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" transition={createDrawTransition(0.3, 0.42)} variants={DRAW_VARIANTS}/>
<motion.path d="M22 13h-4" transition={createDrawTransition(0.42)} variants={DRAW_VARIANTS}/>
<motion.path d="M6 13H2" transition={createDrawTransition(0.46)} variants={DRAW_VARIANTS}/>
<motion.path d="M21 21a4 4 0 0 0-3.81-4" transition={createDrawTransition(0.52)} variants={DRAW_VARIANTS}/>
<motion.path d="M3 21a4 4 0 0 1 3.81-4" transition={createDrawTransition(0.56)} variants={DRAW_VARIANTS}/>
</>);
}
function BulbPaths() {
return (<>
<motion.path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5" transition={createDrawTransition(0, 0.46)} variants={DRAW_VARIANTS}/>
<motion.path d="M9 18h6" transition={createDrawTransition(0.16)} variants={DRAW_VARIANTS}/>
<motion.path d="M10 22h4" transition={createDrawTransition(0.24)} variants={DRAW_VARIANTS}/>
</>);
}
function ReportIconGroup({ mode }: { mode: ReportIconMode }) {
return (<motion.g animate="visible" exit="exit" initial="hidden" variants={GROUP_VARIANTS}>
{mode === "bug" ? <BugPaths/> : <BulbPaths/>}
</motion.g>);
}
function StaticBugIcon() {
return (<g>
<path d="m8 2 1.88 1.88"/>
<path d="M14.12 3.88 16 2"/>
<path d="M9 7.13V6a3 3 0 1 1 6 0v1.13"/>
<path d="M6.53 9A4 4 0 0 1 3 5"/>
<path d="M17.47 9A4 4 0 0 0 21 5"/>
<path d="M12 20v-9"/>
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z"/>
<path d="M22 13h-4"/>
<path d="M6 13H2"/>
<path d="M21 21a4 4 0 0 0-3.81-4"/>
<path d="M3 21a4 4 0 0 1 3.81-4"/>
</g>);
}
function BugReportIcon({ className, size = 28, loop = false, ...props }: BugReportIconProps) {
const [mode, setMode] = useState<ReportIconMode>("bug");
useEffect(() => {
if (!loop) {
setMode("bug");
return;
}
const intervalId = window.setInterval(() => {
setMode((currentMode) => currentMode === "bug" ? "bulb" : "bug");
}, LOOP_INTERVAL_MS);
return () => window.clearInterval(intervalId);
}, [loop]);
return (<div className={cn("flex items-center justify-center", className)} {...props}>
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
{loop ? (<AnimatePresence>
<ReportIconGroup key={mode} mode={mode}/>
</AnimatePresence>) : (<StaticBugIcon/>)}
</svg>
</div>);
}
export { BugReportIcon };
-102
View File
@@ -1,102 +0,0 @@
"use client";
import type { Variants } from "motion/react";
import { motion, useAnimation } from "motion/react";
import type { HTMLAttributes } from "react";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { cn } from "@/lib/utils";
export interface GithubIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface GithubIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const BODY_VARIANTS: Variants = {
normal: {
opacity: 1,
pathLength: 1,
scale: 1,
transition: {
duration: 0.3,
},
},
animate: {
opacity: [0, 1],
pathLength: [0, 1],
scale: [0.9, 1],
transition: {
duration: 0.4,
},
},
};
const TAIL_VARIANTS: Variants = {
normal: {
pathLength: 1,
rotate: 0,
transition: {
duration: 0.3,
},
},
draw: {
pathLength: [0, 1],
rotate: 0,
transition: {
duration: 0.5,
},
},
wag: {
pathLength: 1,
rotate: [0, -15, 15, -10, 10, -5, 5],
transition: {
duration: 2.5,
ease: "easeInOut",
repeat: Number.POSITIVE_INFINITY,
},
},
};
const GithubIcon = forwardRef<GithubIconHandle, GithubIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const bodyControls = useAnimation();
const tailControls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: async () => {
bodyControls.start("animate");
await tailControls.start("draw");
tailControls.start("wag");
},
stopAnimation: () => {
bodyControls.start("normal");
tailControls.start("normal");
},
};
});
const handleMouseEnter = useCallback(async (e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseEnter?.(e);
}
else {
bodyControls.start("animate");
await tailControls.start("draw");
tailControls.start("wag");
}
}, [bodyControls, onMouseEnter, tailControls]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseLeave?.(e);
}
else {
bodyControls.start("normal");
tailControls.start("normal");
}
}, [bodyControls, tailControls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
<motion.path animate={bodyControls} d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" initial="normal" variants={BODY_VARIANTS}/>
<motion.path animate={tailControls} d="M9 18c-4.51 2-5-2-7-2" initial="normal" variants={TAIL_VARIANTS}/>
</svg>
</div>);
});
GithubIcon.displayName = "GithubIcon";
export { GithubIcon };
+17 -7
View File
@@ -37,14 +37,24 @@ function SelectContent({ className, children, position = "popper", align = "cent
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (<SelectPrimitive.Label data-slot="select-label" className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} {...props}/>);
}
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (<SelectPrimitive.Item data-slot="select-item" className={cn("focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", className)} {...props}>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4"/>
</SelectPrimitive.ItemIndicator>
function SelectItem({ className, children, indicatorPosition = "right", trailingAction, ...props }: React.ComponentProps<typeof SelectPrimitive.Item> & {
indicatorPosition?: "right" | "inline";
trailingAction?: React.ReactNode;
}) {
return (<SelectPrimitive.Item data-slot="select-item" className={cn("focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", indicatorPosition === "right" ? "pr-8" : "pr-2", trailingAction ? "pr-10" : undefined, className)} {...props}>
<span className="flex min-w-0 items-center gap-2">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{indicatorPosition === "inline" && (<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4"/>
</SelectPrimitive.ItemIndicator>)}
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{trailingAction ? (<span className="absolute right-2 flex items-center justify-center">
{trailingAction}
</span>) : indicatorPosition === "right" ? (<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4"/>
</SelectPrimitive.ItemIndicator>
</span>) : null}
</SelectPrimitive.Item>);
}
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
+17
View File
@@ -0,0 +1,17 @@
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
function Slider({ className, defaultValue, value, min = 0, max = 100, ...props }: React.ComponentProps<typeof SliderPrimitive.Root>) {
const values = Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min];
return (<SliderPrimitive.Root data-slot="slider" defaultValue={defaultValue} value={value} min={min} max={max} className={cn("relative flex w-full touch-none select-none items-center data-[disabled]:opacity-50", className)} {...props}>
<SliderPrimitive.Track data-slot="slider-track" className="relative h-2 w-full grow overflow-hidden rounded-full bg-muted">
<SliderPrimitive.Range data-slot="slider-range" className="absolute h-full rounded-full bg-primary"/>
</SliderPrimitive.Track>
{values.map((_, index) => (<SliderPrimitive.Thumb key={index} data-slot="slider-thumb" className="block size-4 shrink-0 rounded-full border-2 border-primary bg-background shadow-sm transition-[color,box-shadow] hover:shadow-md focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50"/>))}
</SliderPrimitive.Root>);
}
export { Slider };
+89
View File
@@ -0,0 +1,89 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface ToolCaseIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface ToolCaseIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const DRAW_VARIANTS: Variants = {
normal: {
pathLength: 1,
opacity: 1,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
transition: {
duration: 0.6,
ease: 'easeInOut',
},
},
};
const HANDLE_VARIANTS: Variants = {
normal: {
scaleX: 1,
originX: '50%',
},
animate: {
scaleX: [0.6, 1.1, 1],
originX: '50%',
transition: {
duration: 0.45,
ease: 'easeInOut',
},
},
};
const ToolCaseIcon = forwardRef<ToolCaseIconHandle, ToolCaseIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
}
else {
onMouseEnter?.(e);
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
}
else {
onMouseLeave?.(e);
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<motion.path d="M10 15h4" variants={HANDLE_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="m14.817 10.995-.971-1.45 1.034-1.232a2 2 0 0 0-2.025-3.238l-1.82.364L9.91 3.885a2 2 0 0 0-3.625.748L6.141 6.55l-1.725.426a2 2 0 0 0-.19 3.756l.657.27" variants={DRAW_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="m18.822 10.995 2.26-5.38a1 1 0 0 0-.557-1.318L16.954 2.9a1 1 0 0 0-1.281.533l-.924 2.122" variants={DRAW_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="M4 12.006A1 1 0 0 1 4.994 11H19a1 1 0 0 1 1 1v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" variants={DRAW_VARIANTS} animate={controls} initial="normal"/>
</svg>
</div>);
});
ToolCaseIcon.displayName = 'ToolCaseIcon';
export { ToolCaseIcon };
+3 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { API_SOURCES, checkApiStatus, getApiStatusState, subscribeApiStatus, } from "@/lib/api-status";
import { API_SOURCES, checkApiStatus, checkCurrentApiStatusesOnly, checkSpotiFLACNextStatusesOnly, getApiStatusState, subscribeApiStatus, } from "@/lib/api-status";
export function useApiStatus() {
const [state, setState] = useState(getApiStatusState);
useEffect(() => {
@@ -11,5 +11,7 @@ export function useApiStatus() {
...state,
sources: API_SOURCES,
checkOne: (sourceId: string) => checkApiStatus(sourceId),
checkAllCurrent: () => checkCurrentApiStatusesOnly(),
checkAllNext: () => checkSpotiFLACNextStatusesOnly(),
};
}
+55 -47
View File
@@ -1,6 +1,6 @@
import { useState, useRef } from "react";
import { downloadTrack, fetchSpotifyMetadata } from "@/lib/api";
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
import { getSettings, hasConfiguredCustomTidalApi, parseTemplate, sanitizeAutoOrder, type TemplateData } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils";
import { logger } from "@/lib/logger";
@@ -36,13 +36,17 @@ const GetTrackISRC = (spotifyId: string): Promise<string> => (window as any)["go
async function resolveTemplateISRC(settings: {
folderTemplate?: string;
filenameTemplate?: string;
existingFileCheckMode?: string;
}, spotifyId?: string): Promise<string> {
if (!spotifyId) {
return "";
}
const folderTemplate = settings.folderTemplate || "";
const filenameTemplate = settings.filenameTemplate || "";
if (!folderTemplate.includes("{isrc}") && !filenameTemplate.includes("{isrc}")) {
const shouldResolveISRC = settings.existingFileCheckMode === "isrc" ||
folderTemplate.includes("{isrc}") ||
filenameTemplate.includes("{isrc}");
if (!shouldResolveISRC) {
return "";
}
try {
@@ -52,26 +56,18 @@ async function resolveTemplateISRC(settings: {
return "";
}
}
function getTidalVariant(settings: any): "tidal" | "alt" {
return settings?.tidalVariant === "alt" ? "alt" : "tidal";
}
function isTidalAltVariant(settings: any): boolean {
return getTidalVariant(settings) === "alt";
}
function getTidalAudioFormat(settings: any, mode: "single" | "auto"): "LOSSLESS" | "HI_RES_LOSSLESS" {
if (isTidalAltVariant(settings)) {
return "LOSSLESS";
}
if (mode === "auto") {
return (settings.autoQuality || "24") === "24" ? "HI_RES_LOSSLESS" : "LOSSLESS";
}
return settings.tidalQuality || "LOSSLESS";
}
function shouldFetchStreamingURLs(order: string[], settings: any): boolean {
return order.includes("amazon") || (order.includes("tidal") && !isTidalAltVariant(settings));
function shouldFetchStreamingURLs(order: string[]): boolean {
return order.includes("amazon") || order.includes("tidal");
}
export function useDownload(region: string) {
const [downloadProgress, setDownloadProgress] = useState<number>(0);
const [downloadRemainingCount, setDownloadRemainingCount] = useState<number>(0);
const [isDownloading, setIsDownloading] = useState(false);
const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null);
const [bulkDownloadType, setBulkDownloadType] = useState<"all" | "selected" | null>(null);
@@ -83,10 +79,20 @@ export function useDownload(region: string) {
artists: string;
} | null>(null);
const shouldStopDownloadRef = useRef(false);
const updateBatchProgress = (completedCount: number, totalCount: number) => {
const safeTotalCount = Math.max(0, totalCount);
const safeCompletedCount = Math.min(Math.max(0, completedCount), safeTotalCount);
setDownloadProgress(safeTotalCount > 0 ? Math.min(100, Math.round((safeCompletedCount / safeTotalCount) * 100)) : 0);
setDownloadRemainingCount(Math.max(0, safeTotalCount - safeCompletedCount));
};
const downloadWithAutoFallback = async (id: string, settings: any, trackName?: string, artistName?: string, albumName?: string, playlistName?: string, position?: number, spotifyId?: string, durationMs?: number, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
const service = settings.downloader;
const allowTidal = hasConfiguredCustomTidalApi(settings.customTidalApi);
const service = settings.downloader === "tidal" && !allowTidal ? "auto" : settings.downloader;
const query = trackName && artistName ? `${trackName} ${artistName} ` : undefined;
const os = settings.operatingSystem;
const customTidalApi = allowTidal && typeof settings.customTidalApi === "string" && settings.customTidalApi.trim().startsWith("https://")
? settings.customTidalApi.trim().replace(/\/+$/g, "")
: undefined;
let outputDir = settings.downloadPath;
let useAlbumTrackNumber = false;
const placeholder = "__SLASH_PLACEHOLDER__";
@@ -188,11 +194,9 @@ export function useDownload(region: string) {
itemID = await AddToDownloadQueue(id, trackName || "", displayArtist || "", albumName || "");
}
if (service === "auto") {
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
const tidalVariant = getTidalVariant(settings);
const tidalLabel = tidalVariant === "alt" ? "Tidal Alt." : "Tidal";
const order = sanitizeAutoOrder(settings.autoOrder, allowTidal).split("-");
let streamingURLs: any = null;
if (spotifyId && shouldFetchStreamingURLs(order, settings)) {
if (spotifyId && shouldFetchStreamingURLs(order)) {
try {
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
const urlsJson = await GetStreamingURLs(spotifyId, region);
@@ -209,9 +213,9 @@ export function useDownload(region: string) {
const is24Bit = (settings.autoQuality || "24") === "24";
const qobuzQuality = is24Bit ? "27" : "6";
for (const s of order) {
if (s === "tidal" && ((tidalVariant === "alt" && spotifyId) || streamingURLs?.tidal_url)) {
if (s === "tidal" && streamingURLs?.tidal_url) {
try {
logger.debug(`trying ${tidalLabel} for: ${trackName} - ${artistName}`);
logger.debug(`trying Tidal for: ${trackName} - ${artistName}`);
const response = await downloadTrack({
service: "tidal",
query,
@@ -229,11 +233,11 @@ export function useDownload(region: string) {
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
service_url: tidalVariant === "alt" ? undefined : streamingURLs?.tidal_url,
tidal_variant: tidalVariant,
service_url: streamingURLs?.tidal_url,
duration: durationSeconds,
item_id: itemID,
audio_format: tidalQuality,
tidal_api_url: customTidalApi,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
@@ -246,17 +250,17 @@ export function useDownload(region: string) {
embed_genre: settings.embedGenre,
});
if (response.success) {
logger.success(`${tidalLabel}: ${trackName} - ${artistName}`);
logger.success(`Tidal: ${trackName} - ${artistName}`);
return response;
}
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[${tidalLabel}] ${errMsg}`);
fallbackErrors.push(`[Tidal] ${errMsg}`);
lastResponse = response;
logger.warning(`${tidalLabel} failed, trying next...`);
logger.warning(`Tidal failed, trying next...`);
}
catch (err) {
logger.error(`${tidalLabel} error: ${err}`);
fallbackErrors.push(`[${tidalLabel}] ${String(err)}`);
logger.error(`Tidal error: ${err}`);
fallbackErrors.push(`[Tidal] ${String(err)}`);
lastResponse = { success: false, error: String(err) };
}
}
@@ -394,7 +398,7 @@ export function useDownload(region: string) {
duration: durationSecondsForFallback,
item_id: itemID,
audio_format: audioFormat,
tidal_variant: service === "tidal" ? getTidalVariant(settings) : undefined,
tidal_api_url: service === "tidal" ? customTidalApi : undefined,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
@@ -413,7 +417,8 @@ export function useDownload(region: string) {
return singleServiceResponse;
};
const downloadWithItemID = async (settings: any, itemID: string, trackName?: string, artistName?: string, albumName?: string, folderName?: string, position?: number, spotifyId?: string, durationMs?: number, isAlbum?: boolean, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
const service = settings.downloader;
const allowTidal = hasConfiguredCustomTidalApi(settings.customTidalApi);
const service = settings.downloader === "tidal" && !allowTidal ? "auto" : settings.downloader;
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
@@ -474,11 +479,9 @@ export function useDownload(region: string) {
}
}
if (service === "auto") {
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
const tidalVariant = getTidalVariant(settings);
const tidalLabel = tidalVariant === "alt" ? "Tidal Alt." : "Tidal";
const order = sanitizeAutoOrder(settings.autoOrder, allowTidal).split("-");
let streamingURLs: any = null;
if (spotifyId && shouldFetchStreamingURLs(order, settings)) {
if (spotifyId && shouldFetchStreamingURLs(order)) {
try {
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
const urlsJson = await GetStreamingURLs(spotifyId, region);
@@ -495,9 +498,9 @@ export function useDownload(region: string) {
const is24Bit = (settings.autoQuality || "24") === "24";
const qobuzQuality = is24Bit ? "27" : "6";
for (const s of order) {
if (s === "tidal" && ((tidalVariant === "alt" && spotifyId) || streamingURLs?.tidal_url)) {
if (s === "tidal" && streamingURLs?.tidal_url) {
try {
logger.debug(`trying ${tidalLabel} for: ${trackName} - ${artistName}`);
logger.debug(`trying Tidal for: ${trackName} - ${artistName}`);
const response = await downloadTrack({
service: "tidal",
query,
@@ -515,8 +518,7 @@ export function useDownload(region: string) {
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
service_url: tidalVariant === "alt" ? undefined : streamingURLs?.tidal_url,
tidal_variant: tidalVariant,
service_url: streamingURLs?.tidal_url,
duration: durationSeconds,
item_id: itemID,
audio_format: tidalQuality,
@@ -532,17 +534,17 @@ export function useDownload(region: string) {
embed_genre: settings.embedGenre,
});
if (response.success) {
logger.success(`${tidalLabel}: ${trackName} - ${artistName}`);
logger.success(`Tidal: ${trackName} - ${artistName}`);
return response;
}
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[${tidalLabel}] ${errMsg}`);
fallbackErrors.push(`[Tidal] ${errMsg}`);
lastResponse = response;
logger.warning(`${tidalLabel} failed, trying next...`);
logger.warning(`Tidal failed, trying next...`);
}
catch (err) {
logger.error(`${tidalLabel} error: ${err}`);
fallbackErrors.push(`[${tidalLabel}] ${String(err)}`);
logger.error(`Tidal error: ${err}`);
fallbackErrors.push(`[Tidal] ${String(err)}`);
lastResponse = { success: false, error: String(err) };
}
}
@@ -679,7 +681,6 @@ export function useDownload(region: string) {
duration: durationSecondsForFallback,
item_id: itemID,
audio_format: audioFormat,
tidal_variant: service === "tidal" ? getTidalVariant(settings) : undefined,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
@@ -747,6 +748,8 @@ export function useDownload(region: string) {
setIsDownloading(true);
setBulkDownloadType("selected");
setDownloadProgress(0);
setDownloadRemainingCount(selectedTracks.length);
setCurrentDownloadInfo(null);
let outputDir = settings.downloadPath;
const os = settings.operatingSystem;
const useAlbumTag = settings.folderTemplate?.includes("{album}");
@@ -815,7 +818,7 @@ export function useDownload(region: string) {
let errorCount = 0;
let skippedCount = existingSpotifyIDs.size;
const total = selectedTracks.length;
setDownloadProgress(Math.round((skippedCount / total) * 100));
updateBatchProgress(skippedCount, total);
for (let i = 0; i < tracksToDownload.length; i++) {
if (shouldStopDownloadRef.current) {
toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`);
@@ -868,12 +871,13 @@ export function useDownload(region: string) {
}
}
const completedCount = skippedCount + successCount + errorCount;
setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
updateBatchProgress(completedCount, total);
}
setDownloadingTrack(null);
setCurrentDownloadInfo(null);
setIsDownloading(false);
setBulkDownloadType(null);
updateBatchProgress(0, 0);
shouldStopDownloadRef.current = false;
const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App");
await CancelAllQueuedItems();
@@ -922,6 +926,8 @@ export function useDownload(region: string) {
setIsDownloading(true);
setBulkDownloadType("all");
setDownloadProgress(0);
setDownloadRemainingCount(tracksWithId.length);
setCurrentDownloadInfo(null);
let outputDir = settings.downloadPath;
const os = settings.operatingSystem;
const useAlbumTag = settings.folderTemplate?.includes("{album}");
@@ -985,7 +991,7 @@ export function useDownload(region: string) {
let errorCount = 0;
let skippedCount = existingSpotifyIDs.size;
const total = tracksWithId.length;
setDownloadProgress(Math.round((skippedCount / total) * 100));
updateBatchProgress(skippedCount, total);
for (let i = 0; i < tracksToDownload.length; i++) {
if (shouldStopDownloadRef.current) {
toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`);
@@ -1035,12 +1041,13 @@ export function useDownload(region: string) {
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
}
const completedCount = skippedCount + successCount + errorCount;
setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
updateBatchProgress(completedCount, total);
}
setDownloadingTrack(null);
setCurrentDownloadInfo(null);
setIsDownloading(false);
setBulkDownloadType(null);
updateBatchProgress(0, 0);
shouldStopDownloadRef.current = false;
const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App");
await CancelQueued();
@@ -1087,6 +1094,7 @@ export function useDownload(region: string) {
};
return {
downloadProgress,
downloadRemainingCount,
isDownloading,
downloadingTrack,
bulkDownloadType,
+5 -1
View File
@@ -9,13 +9,17 @@ const GetTrackISRC = (spotifyId: string): Promise<string> => (window as any)["go
async function resolveTemplateISRC(settings: {
folderTemplate?: string;
filenameTemplate?: string;
existingFileCheckMode?: string;
}, spotifyId?: string): Promise<string> {
if (!spotifyId) {
return "";
}
const folderTemplate = settings.folderTemplate || "";
const filenameTemplate = settings.filenameTemplate || "";
if (!folderTemplate.includes("{isrc}") && !filenameTemplate.includes("{isrc}")) {
const shouldResolveISRC = settings.existingFileCheckMode === "isrc" ||
folderTemplate.includes("{isrc}") ||
filenameTemplate.includes("{isrc}");
if (!shouldResolveISRC) {
return "";
}
try {
+32 -27
View File
@@ -1,32 +1,34 @@
import { useState, useEffect } from "react";
import { useEffect, useRef, useState } from "react";
import { GetPreviewURL } from "@/../wailsjs/go/main/App";
import { SPOTIFY_PREVIEW_VOLUME } from "@/lib/preview";
import { getPreviewVolume } from "@/lib/preview";
import { createPreviewPlayback, type PreviewPlayback } from "@/lib/preview-player";
import { toast } from "sonner";
export function usePreview() {
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
const [currentAudio, setCurrentAudio] = useState<HTMLAudioElement | null>(null);
const [playingTrack, setPlayingTrack] = useState<string | null>(null);
const currentPlaybackRef = useRef<PreviewPlayback | null>(null);
const stopCurrentAudio = () => {
if (!currentPlaybackRef.current) {
return;
}
currentPlaybackRef.current.destroy();
currentPlaybackRef.current = null;
};
useEffect(() => {
return () => {
if (currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
}
stopCurrentAudio();
};
}, [currentAudio]);
}, []);
const playPreview = async (trackId: string, trackName: string) => {
try {
const currentAudio = currentPlaybackRef.current?.audio;
if (playingTrack === trackId && currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
stopCurrentAudio();
setPlayingTrack(null);
setCurrentAudio(null);
return;
}
if (currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
setCurrentAudio(null);
stopCurrentAudio();
setPlayingTrack(null);
}
setLoadingPreview(trackId);
@@ -38,15 +40,18 @@ export function usePreview() {
setLoadingPreview(null);
return;
}
const audio = new Audio(previewURL);
audio.volume = SPOTIFY_PREVIEW_VOLUME;
const playback = await createPreviewPlayback(previewURL, getPreviewVolume());
const audio = playback.audio;
audio.addEventListener("loadeddata", () => {
setLoadingPreview(null);
setPlayingTrack(trackId);
});
audio.addEventListener("ended", () => {
setPlayingTrack(null);
setCurrentAudio(null);
if (currentPlaybackRef.current?.audio === audio) {
currentPlaybackRef.current.destroy();
currentPlaybackRef.current = null;
}
});
audio.addEventListener("error", () => {
toast.error("Failed to play preview", {
@@ -54,27 +59,27 @@ export function usePreview() {
});
setLoadingPreview(null);
setPlayingTrack(null);
setCurrentAudio(null);
if (currentPlaybackRef.current?.audio === audio) {
currentPlaybackRef.current.destroy();
currentPlaybackRef.current = null;
}
});
setCurrentAudio(audio);
currentPlaybackRef.current = playback;
await audio.play();
}
catch (error: any) {
catch (error: unknown) {
stopCurrentAudio();
console.error("Preview error:", error);
toast.error("Preview not available", {
description: error?.message || `Could not load preview for "${trackName}"`,
description: error instanceof Error ? error.message : `Could not load preview for "${trackName}"`,
});
setLoadingPreview(null);
setPlayingTrack(null);
}
};
const stopPreview = () => {
if (currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
setCurrentAudio(null);
setPlayingTrack(null);
}
stopCurrentAudio();
setPlayingTrack(null);
};
return {
playPreview,
+265 -88
View File
@@ -1,84 +1,166 @@
import { CheckAPIStatus } from "../../wailsjs/go/main/App";
import { CheckAPIStatus, CheckCustomTidalAPI } from "../../wailsjs/go/main/App";
import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout";
import { getSettings, hasConfiguredCustomTidalApi } from "@/lib/settings";
export type ApiCheckStatus = "checking" | "online" | "offline" | "idle";
export interface ApiSource {
id: string;
type: string;
name: string;
url: string;
}
interface SpotiFLACNextSource {
id: string;
name: string;
statusKey?: string;
statusPrefix?: string;
}
type SpotiFLACNextStatusResponse = {
tidal?: string;
qobuz_a?: string;
qobuz_b?: string;
qobuz_c?: string;
deezer_a?: string;
deezer_b?: string;
amazon_a?: string;
amazon_b?: string;
amazon_c?: string;
apple?: string;
type SpotiFLACNextStatusResponse = Partial<Record<string, string>>;
type ApiStatusTargetReport = {
target?: string;
label?: string;
online?: boolean;
message?: string;
};
type ApiStatusReport = {
type?: string;
online?: boolean;
require_all?: boolean;
details?: ApiStatusTargetReport[];
};
export const API_SOURCES: ApiSource[] = [
{ id: "tidal", type: "tidal", name: "Tidal", url: "" },
{ id: "qobuz", type: "qobuz", name: "Qobuz", url: "" },
{ id: "amazon", type: "amazon", name: "Amazon Music", url: "" },
{ id: "musicbrainz", type: "musicbrainz", name: "MusicBrainz", url: "https://musicbrainz.org" },
];
export const SPOTIFLAC_NEXT_SOURCES: SpotiFLACNextSource[] = [
{ id: "tidal", name: "Tidal" },
{ id: "qobuz", name: "Qobuz" },
{ id: "amazon", name: "Amazon Music" },
{ id: "deezer", name: "Deezer" },
{ id: "apple", name: "Apple Music" },
{ id: "tidal", name: "Tidal", statusKey: "tidal" },
{ id: "qobuz", name: "Qobuz", statusPrefix: "qobuz_" },
{ id: "amazon", name: "Amazon Music", statusPrefix: "amazon_" },
{ id: "deezer", name: "Deezer", statusPrefix: "deezer_" },
{ id: "apple", name: "Apple Music", statusKey: "apple" },
];
const SPOTIFLAC_NEXT_STATUS_URL = "https://status.spotbye.qzz.io/status";
const SPOTIFLAC_NEXT_MAX_ATTEMPTS = 3;
const SPOTIFLAC_NEXT_RETRY_DELAY_MS = 1200;
const SPOTIFLAC_STATUS_URL = "https://gist.githubusercontent.com/afkarxyz/6e57cd362cbd67f889e3a91a76254a5e/raw";
const SPOTIFLAC_CURRENT_AMAZON_STATUS_KEY = "amazon_a";
const SPOTIFLAC_STATUS_MAX_ATTEMPTS = 3;
const SPOTIFLAC_STATUS_RETRY_DELAY_MS = 1200;
const CheckAPIStatusReport = (apiType: string, apiURL: string): Promise<ApiStatusReport> => (window as any)["go"]["main"]["App"]["CheckAPIStatusReport"](apiType, apiURL);
const LogStatusConsole = (level: string, message: string): Promise<void> => (window as any)["go"]["main"]["App"]["LogStatusConsole"](level, message);
type ApiStatusState = {
checkingSources: Record<string, boolean>;
statuses: Record<string, ApiCheckStatus>;
nextStatuses: Record<string, ApiCheckStatus>;
};
let apiStatusState: ApiStatusState = {
checkingSources: {},
statuses: {},
nextStatuses: {},
};
let activeCheckCurrentOnly: Promise<void> | null = null;
let activeCheckNextOnly: Promise<void> | null = null;
let activeStatusPayloadFetch: Promise<SpotiFLACNextStatusResponse> | null = null;
const activeSourceChecks = new Map<string, Promise<void>>();
const listeners = new Set<() => void>();
function emitApiStatusChange() {
for (const listener of listeners) {
listener();
}
}
function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState) {
apiStatusState = updater(apiStatusState);
emitApiStatusChange();
}
async function checkSourceStatus(source: ApiSource): Promise<ApiCheckStatus> {
try {
const isOnline = await withTimeout(CheckAPIStatus(source.type, source.url), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.name}`);
return isOnline ? "online" : "offline";
}
catch {
return "offline";
}
}
function statusFromNextValue(value: string | undefined): ApiCheckStatus {
return value === "up" ? "online" : "offline";
}
function anyNextVariantUp(values: Array<string | undefined>): ApiCheckStatus {
return values.some((value) => value === "up") ? "online" : "offline";
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function sendStatusConsole(level: "info" | "warning" | "error", message: string): void {
try {
void LogStatusConsole(level, message);
}
catch {
return;
}
}
function logStatusInfo(message: string): void {
sendStatusConsole("info", message);
}
function logStatusWarning(message: string): void {
sendStatusConsole("warning", message);
}
function logStatusError(message: string): void {
sendStatusConsole("error", message);
}
function truncateStatusMessage(message?: string, maxLen = 180): string {
const trimmed = (message || "").trim();
if (trimmed.length <= maxLen) {
return trimmed;
}
return trimmed.slice(0, maxLen) + "...";
}
function logQobuzStatusReport(report: ApiStatusReport): void {
const details = Array.isArray(report.details) ? report.details : [];
if (details.length === 0) {
logStatusWarning("[Status][Qobuz] No provider details were returned.");
return;
}
const onlineCount = details.filter((detail) => detail.online === true).length;
logStatusInfo(`[Status][Qobuz] Provider check completed: ${onlineCount}/${details.length} providers online.`);
for (const detail of details) {
const label = detail.label || detail.target || "Unknown provider";
const suffix = detail.message ? ` - ${truncateStatusMessage(detail.message)}` : "";
if (detail.online) {
logStatusInfo(`[Status][Qobuz] ${label}: online${suffix}`);
}
else {
logStatusWarning(`[Status][Qobuz] ${label}: offline${suffix}`);
}
}
if (report.online) {
logStatusInfo(`[Status][Qobuz] SpotiFLAC Qobuz is online (${onlineCount}/${details.length} providers online).`);
}
else {
logStatusWarning(`[Status][Qobuz] SpotiFLAC Qobuz marked maintenance because all ${details.length} providers are offline.`);
}
}
function anyNextVariantUp(values: Array<string | undefined>): ApiCheckStatus {
return values.some((value) => value === "up") ? "online" : "offline";
}
function getNextSourceValues(payload: SpotiFLACNextStatusResponse, source: SpotiFLACNextSource): string[] {
if (source.statusKey) {
const value = payload[source.statusKey];
return typeof value === "string" ? [value] : [];
}
if (!source.statusPrefix) {
return [];
}
const values: string[] = [];
for (const [key, value] of Object.entries(payload)) {
if (key.startsWith(source.statusPrefix) && typeof value === "string") {
values.push(value);
}
}
return values;
}
function getCurrentAmazonStatus(payload: SpotiFLACNextStatusResponse): ApiCheckStatus {
return payload[SPOTIFLAC_CURRENT_AMAZON_STATUS_KEY] === "up" ? "online" : "offline";
}
function getSafeNextStatusesFallback(currentStatuses: Record<string, ApiCheckStatus>): Record<string, ApiCheckStatus> {
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
const current = currentStatuses[source.id];
@@ -86,60 +168,142 @@ function getSafeNextStatusesFallback(currentStatuses: Record<string, ApiCheckSta
return acc;
}, {});
}
async function fetchSpotiFLACNextStatusesOnce(): Promise<Record<string, ApiCheckStatus>> {
const response = await withTimeout(fetch(SPOTIFLAC_NEXT_STATUS_URL, {
method: "GET",
cache: "no-store",
headers: {
Accept: "application/json",
},
}), CHECK_TIMEOUT_MS, "SpotiFLAC Next status check timed out after 10 seconds");
if (!response.ok) {
throw new Error(`SpotiFLAC Next status returned ${response.status}`);
}
const payload = (await response.json()) as SpotiFLACNextStatusResponse;
return {
tidal: statusFromNextValue(payload.tidal),
qobuz: anyNextVariantUp([payload.qobuz_a, payload.qobuz_b, payload.qobuz_c]),
deezer: anyNextVariantUp([payload.deezer_a, payload.deezer_b]),
amazon: anyNextVariantUp([payload.amazon_a, payload.amazon_b, payload.amazon_c]),
apple: statusFromNextValue(payload.apple),
};
}
async function checkSpotiFLACNextStatuses(): Promise<Record<string, ApiCheckStatus>> {
let lastError: unknown = null;
for (let attempt = 1; attempt <= SPOTIFLAC_NEXT_MAX_ATTEMPTS; attempt++) {
try {
return await fetchSpotiFLACNextStatusesOnce();
}
catch (error) {
lastError = error;
if (attempt < SPOTIFLAC_NEXT_MAX_ATTEMPTS) {
await delay(SPOTIFLAC_NEXT_RETRY_DELAY_MS * attempt);
}
}
}
throw lastError instanceof Error ? lastError : new Error("SpotiFLAC Next status check failed");
}
export function getApiStatusState(): ApiStatusState {
return apiStatusState;
}
export function subscribeApiStatus(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
function hasCurrentResults(): boolean {
return API_SOURCES.some((source) => {
const status = apiStatusState.statuses[source.id];
return status === "online" || status === "offline";
});
}
function hasSpotiFLACNextResults(): boolean {
return SPOTIFLAC_NEXT_SOURCES.some((source) => {
const status = apiStatusState.nextStatuses[source.id];
return status === "online" || status === "offline";
});
}
async function fetchSpotiFLACStatusPayloadOnce(): Promise<SpotiFLACNextStatusResponse> {
const response = await withTimeout(fetch(SPOTIFLAC_STATUS_URL, {
method: "GET",
cache: "no-store",
headers: {
Accept: "application/json",
},
}), CHECK_TIMEOUT_MS, "SpotiFLAC status check timed out after 10 seconds");
if (!response.ok) {
throw new Error(`SpotiFLAC status returned ${response.status}`);
}
return (await response.json()) as SpotiFLACNextStatusResponse;
}
async function fetchSpotiFLACStatusPayload(): Promise<SpotiFLACNextStatusResponse> {
if (activeStatusPayloadFetch) {
return activeStatusPayloadFetch;
}
activeStatusPayloadFetch = (async () => {
let lastError: unknown = null;
for (let attempt = 1; attempt <= SPOTIFLAC_STATUS_MAX_ATTEMPTS; attempt++) {
try {
return await fetchSpotiFLACStatusPayloadOnce();
}
catch (error) {
lastError = error;
if (attempt < SPOTIFLAC_STATUS_MAX_ATTEMPTS) {
await delay(SPOTIFLAC_STATUS_RETRY_DELAY_MS * attempt);
}
}
}
throw lastError instanceof Error ? lastError : new Error("SpotiFLAC status check failed");
})();
try {
return await activeStatusPayloadFetch;
}
finally {
activeStatusPayloadFetch = null;
}
}
async function checkSourceStatus(source: ApiSource): Promise<ApiCheckStatus> {
try {
if (source.id === "tidal") {
const customTidalApi = getSettings().customTidalApi;
if (!hasConfiguredCustomTidalApi(customTidalApi)) {
logStatusWarning("[Status][Tidal] Marked maintenance because no custom Tidal instance is configured.");
return "offline";
}
const isOnline = await withTimeout(CheckCustomTidalAPI(customTidalApi), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.name}`);
return isOnline ? "online" : "offline";
}
if (source.id === "amazon") {
const payload = await fetchSpotiFLACStatusPayload();
return getCurrentAmazonStatus(payload);
}
if (source.id === "qobuz") {
logStatusInfo("[Status][Qobuz] Checking current SpotiFLAC providers...");
const report = await withTimeout(CheckAPIStatusReport(source.type, source.url), CHECK_TIMEOUT_MS, `API status report timed out after 10 seconds for ${source.name}`);
logQobuzStatusReport(report);
return report.online ? "online" : "offline";
}
const isOnline = await withTimeout(CheckAPIStatus(source.type, source.url), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.name}`);
return isOnline ? "online" : "offline";
}
catch (error) {
if (source.id === "qobuz") {
logStatusError(`[Status][Qobuz] Provider check failed: ${error instanceof Error ? error.message : String(error)}`);
}
return "offline";
}
}
async function checkSpotiFLACNextStatuses(): Promise<Record<string, ApiCheckStatus>> {
const payload = await fetchSpotiFLACStatusPayload();
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
acc[source.id] = anyNextVariantUp(getNextSourceValues(payload, source));
return acc;
}, {});
}
export function getApiStatusState(): ApiStatusState {
return apiStatusState;
}
export function subscribeApiStatus(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export async function checkCurrentApiStatusesOnly(): Promise<void> {
if (activeCheckCurrentOnly) {
return activeCheckCurrentOnly;
}
activeCheckCurrentOnly = (async () => {
await Promise.all(API_SOURCES.map((source) => checkApiStatus(source.id)));
})();
try {
await activeCheckCurrentOnly;
}
finally {
activeCheckCurrentOnly = null;
}
}
export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
if (activeCheckNextOnly) {
return activeCheckNextOnly;
}
activeCheckNextOnly = (async () => {
const checkingNextStatuses = Object.fromEntries(SPOTIFLAC_NEXT_SOURCES.map((source) => [source.id, "checking" as ApiCheckStatus]));
setApiStatusState((current) => ({
@@ -149,11 +313,8 @@ export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
...checkingNextStatuses,
},
}));
try {
setApiStatusState((current) => ({
...current,
nextStatuses: { ...current.nextStatuses },
}));
const nextStatuses = await checkSpotiFLACNextStatuses();
setApiStatusState((current) => ({
...current,
@@ -169,26 +330,40 @@ export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
nextStatuses: getSafeNextStatusesFallback(current.nextStatuses),
}));
}
finally {
activeCheckNextOnly = null;
}
})();
return activeCheckNextOnly;
try {
await activeCheckNextOnly;
}
finally {
activeCheckNextOnly = null;
}
}
export function ensureSpotiFLACNextStatusCheckStarted(): void {
export function ensureApiStatusCheckStarted(): void {
if (!activeCheckCurrentOnly && !hasCurrentResults()) {
void checkCurrentApiStatusesOnly();
}
if (!activeCheckNextOnly && !hasSpotiFLACNextResults()) {
void checkSpotiFLACNextStatusesOnly();
}
}
export function ensureSpotiFLACNextStatusCheckStarted(): void {
ensureApiStatusCheckStarted();
}
export async function checkApiStatus(sourceId: string): Promise<void> {
const source = API_SOURCES.find((item) => item.id === sourceId);
if (!source) {
return;
}
const activeCheck = activeSourceChecks.get(sourceId);
if (activeCheck) {
return activeCheck;
}
const task = (async () => {
setApiStatusState((current) => ({
...current,
@@ -201,6 +376,7 @@ export async function checkApiStatus(sourceId: string): Promise<void> {
[sourceId]: "checking",
},
}));
try {
const status = await checkSourceStatus(source);
setApiStatusState((current) => ({
@@ -222,6 +398,7 @@ export async function checkApiStatus(sourceId: string): Promise<void> {
activeSourceChecks.delete(sourceId);
}
})();
activeSourceChecks.set(sourceId, task);
return task;
}
-3
View File
@@ -13,9 +13,6 @@ export async function fetchSpotifyMetadata(url: string, batch: boolean = true, d
}
export async function downloadTrack(request: DownloadRequest): Promise<DownloadResponse> {
const req = new main.DownloadRequest(request);
if (request.tidal_variant !== undefined) {
(req as any).tidal_variant = request.tidal_variant;
}
if (request.use_single_genre !== undefined) {
(req as any).use_single_genre = request.use_single_genre;
}
+37
View File
@@ -0,0 +1,37 @@
import { getPreviewVolume, PREVIEW_VOLUME_CHANGED_EVENT } from "@/lib/preview";
export interface PreviewPlayback {
audio: HTMLAudioElement;
destroy: () => void;
}
export async function createPreviewPlayback(url: string, volume: number): Promise<PreviewPlayback> {
const audio = new Audio(url);
const applyVolume = (nextVolume: number) => {
if (!Number.isFinite(nextVolume)) {
return;
}
audio.volume = Math.min(1, Math.max(0, nextVolume));
};
applyVolume(volume);
const handleSettingsUpdated = () => {
applyVolume(getPreviewVolume());
};
const handlePreviewVolumeChanged = (event: Event) => {
const nextVolumePercent = (event as CustomEvent<number>).detail;
if (!Number.isFinite(nextVolumePercent)) {
return;
}
applyVolume(nextVolumePercent / 100);
};
window.addEventListener("settingsUpdated", handleSettingsUpdated);
window.addEventListener(PREVIEW_VOLUME_CHANGED_EVENT, handlePreviewVolumeChanged);
return {
audio,
destroy: () => {
window.removeEventListener("settingsUpdated", handleSettingsUpdated);
window.removeEventListener(PREVIEW_VOLUME_CHANGED_EVENT, handlePreviewVolumeChanged);
audio.pause();
audio.removeAttribute("src");
audio.load();
},
};
}
+9
View File
@@ -1 +1,10 @@
import { getSettings } from "@/lib/settings";
export const SPOTIFY_PREVIEW_VOLUME = 1;
export const PREVIEW_VOLUME_CHANGED_EVENT = "previewVolumeChanged";
export function getPreviewVolume(): number {
const previewVolume = getSettings().previewVolume;
if (!Number.isFinite(previewVolume)) {
return SPOTIFY_PREVIEW_VOLUME;
}
return Math.min(1, Math.max(0, previewVolume / 100));
}
+605 -237
View File
@@ -1,15 +1,32 @@
import { GetDefaults, LoadSettings, SaveSettings as SaveToBackend } from "../../wailsjs/go/main/App";
export type FontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk" | "noto-sans" | "nunito-sans" | "figtree" | "raleway" | "public-sans" | "outfit" | "jetbrains-mono" | "geist-sans" | "bricolage-grotesque";
import { GetDefaults, LoadFonts as LoadFontsFromBackend, LoadSettings, SaveFonts as SaveFontsToBackend, SaveSettings as SaveToBackend, } from "../../wailsjs/go/main/App";
export type BuiltInFontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk" | "noto-sans" | "nunito-sans" | "figtree" | "raleway" | "public-sans" | "outfit" | "jetbrains-mono" | "geist-sans" | "bricolage-grotesque";
export type CustomFontFamily = `custom-${string}`;
export type FontFamily = BuiltInFontFamily | CustomFontFamily;
export interface CustomFontOption {
value: CustomFontFamily;
label: string;
fontFamily: string;
url: string;
}
export type FontOption = {
value: FontFamily;
label: string;
fontFamily: string;
url?: string;
};
export type FolderPreset = "none" | "artist" | "album" | "year-album" | "year-artist-album" | "artist-album" | "artist-year-album" | "artist-year-nested-album" | "album-artist" | "album-artist-album" | "album-artist-year-album" | "album-artist-year-nested-album" | "year" | "year-artist" | "custom";
export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "title-album-artist" | "track-title-album-artist" | "artist-album-title" | "track-dash-title" | "disc-track-title" | "disc-track-title-artist" | "custom";
export type ExistingFileCheckMode = "filename" | "isrc";
export interface Settings {
downloadPath: string;
downloader: "auto" | "tidal" | "qobuz" | "amazon";
customTidalApi: string;
linkResolver: "songstats" | "songlink";
allowResolverFallback: boolean;
theme: string;
themeMode: "auto" | "light" | "dark";
fontFamily: FontFamily;
customFonts: CustomFontOption[];
folderPreset: FolderPreset;
folderTemplate: string;
filenamePreset: FilenamePreset;
@@ -22,7 +39,6 @@ export interface Settings {
embedLyrics: boolean;
embedMaxQualityCover: boolean;
operatingSystem: "Windows" | "linux/MacOS";
tidalVariant: "tidal" | "alt";
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
qobuzQuality: "6" | "7" | "27";
amazonQuality: "original";
@@ -32,6 +48,8 @@ export interface Settings {
createPlaylistFolder: boolean;
playlistOwnerFolderName: boolean;
createM3u8File: boolean;
previewVolume: number;
existingFileCheckMode: ExistingFileCheckMode;
useFirstArtistOnly: boolean;
useSingleGenre: boolean;
embedGenre: boolean;
@@ -42,54 +60,105 @@ export const FOLDER_PRESETS: Record<FolderPreset, {
label: string;
template: string;
}> = {
"none": { label: "No Subfolder", template: "" },
"artist": { label: "Artist", template: "{artist}" },
"album": { label: "Album", template: "{album}" },
none: { label: "No Subfolder", template: "" },
artist: { label: "Artist", template: "{artist}" },
album: { label: "Album", template: "{album}" },
"year-album": { label: "[Year] Album", template: "[{year}] {album}" },
"year-artist-album": { label: "[Year] Artist - Album", template: "[{year}] {artist} - {album}" },
"year-artist-album": {
label: "[Year] Artist - Album",
template: "[{year}] {artist} - {album}",
},
"artist-album": { label: "Artist / Album", template: "{artist}/{album}" },
"artist-year-album": { label: "Artist / [Year] Album", template: "{artist}/[{year}] {album}" },
"artist-year-nested-album": { label: "Artist / Year / Album", template: "{artist}/{year}/{album}" },
"artist-year-album": {
label: "Artist / [Year] Album",
template: "{artist}/[{year}] {album}",
},
"artist-year-nested-album": {
label: "Artist / Year / Album",
template: "{artist}/{year}/{album}",
},
"album-artist": { label: "Album Artist", template: "{album_artist}" },
"album-artist-album": { label: "Album Artist / Album", template: "{album_artist}/{album}" },
"album-artist-year-album": { label: "Album Artist / [Year] Album", template: "{album_artist}/[{year}] {album}" },
"album-artist-year-nested-album": { label: "Album Artist / Year / Album", template: "{album_artist}/{year}/{album}" },
"year": { label: "Year", template: "{year}" },
"album-artist-album": {
label: "Album Artist / Album",
template: "{album_artist}/{album}",
},
"album-artist-year-album": {
label: "Album Artist / [Year] Album",
template: "{album_artist}/[{year}] {album}",
},
"album-artist-year-nested-album": {
label: "Album Artist / Year / Album",
template: "{album_artist}/{year}/{album}",
},
year: { label: "Year", template: "{year}" },
"year-artist": { label: "Year / Artist", template: "{year}/{artist}" },
"custom": { label: "Custom...", template: "{artist}/{album}" },
custom: { label: "Custom...", template: "{artist}/{album}" },
};
export const FILENAME_PRESETS: Record<FilenamePreset, {
label: string;
template: string;
}> = {
"title": { label: "Title", template: "{title}" },
title: { label: "Title", template: "{title}" },
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
"artist-title": { label: "Artist - Title", template: "{artist} - {title}" },
"track-title": { label: "Track. Title", template: "{track}. {title}" },
"track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" },
"track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" },
"title-album-artist": { label: "Title - Album Artist", template: "{title} - {album_artist}" },
"track-title-album-artist": { label: "Track. Title - Album Artist", template: "{track}. {title} - {album_artist}" },
"artist-album-title": { label: "Artist - Album - Title", template: "{artist} - {album} - {title}" },
"track-title-artist": {
label: "Track. Title - Artist",
template: "{track}. {title} - {artist}",
},
"track-artist-title": {
label: "Track. Artist - Title",
template: "{track}. {artist} - {title}",
},
"title-album-artist": {
label: "Title - Album Artist",
template: "{title} - {album_artist}",
},
"track-title-album-artist": {
label: "Track. Title - Album Artist",
template: "{track}. {title} - {album_artist}",
},
"artist-album-title": {
label: "Artist - Album - Title",
template: "{artist} - {album} - {title}",
},
"track-dash-title": { label: "Track - Title", template: "{track} - {title}" },
"disc-track-title": { label: "Disc-Track. Title", template: "{disc}-{track}. {title}" },
"disc-track-title-artist": { label: "Disc-Track. Title - Artist", template: "{disc}-{track}. {title} - {artist}" },
"custom": { label: "Custom...", template: "{title} - {artist}" },
"disc-track-title": {
label: "Disc-Track. Title",
template: "{disc}-{track}. {title}",
},
"disc-track-title-artist": {
label: "Disc-Track. Title - Artist",
template: "{disc}-{track}. {title} - {artist}",
},
custom: { label: "Custom...", template: "{title} - {artist}" },
};
export const TEMPLATE_VARIABLES = [
{ key: "{title}", description: "Track title", example: "Shake It Off" },
{ key: "{artist}", description: "Track artist", example: "Taylor Swift" },
{ key: "{album}", description: "Album name", example: "1989" },
{ key: "{album_artist}", description: "Album artist", example: "Taylor Swift" },
{
key: "{album_artist}",
description: "Album artist",
example: "Taylor Swift",
},
{ key: "{track}", description: "Track number", example: "01" },
{ key: "{disc}", description: "Disc number", example: "1" },
{ key: "{year}", description: "Release year", example: "2014" },
{ key: "{date}", description: "Release date (YYYY-MM-DD)", example: "2014-10-27" },
{ key: "{isrc}", description: "Track ISRC", example: "USUM71412345" },
{
key: "{date}",
description: "Release date (YYYY-MM-DD)",
example: "2014-10-27",
},
{
key: "{isrc}",
description: "Track ISRC",
example: "USUM71412345",
},
];
function detectOS(): "Windows" | "linux/MacOS" {
const platform = window.navigator.platform.toLowerCase();
if (platform.includes('win')) {
if (platform.includes("win")) {
return "Windows";
}
return "linux/MacOS";
@@ -97,11 +166,13 @@ function detectOS(): "Windows" | "linux/MacOS" {
export const DEFAULT_SETTINGS: Settings = {
downloadPath: "",
downloader: "auto",
customTidalApi: "",
linkResolver: "songlink",
allowResolverFallback: true,
theme: "yellow",
themeMode: "auto",
fontFamily: "google-sans",
customFonts: [],
folderPreset: "none",
folderTemplate: "",
filenamePreset: "title-artist",
@@ -111,52 +182,500 @@ export const DEFAULT_SETTINGS: Settings = {
embedLyrics: false,
embedMaxQualityCover: false,
operatingSystem: detectOS(),
tidalVariant: "tidal",
tidalQuality: "LOSSLESS",
qobuzQuality: "6",
amazonQuality: "original",
autoOrder: "tidal-qobuz-amazon",
autoOrder: "qobuz-amazon",
autoQuality: "16",
allowFallback: true,
createPlaylistFolder: true,
playlistOwnerFolderName: false,
createM3u8File: false,
previewVolume: 100,
existingFileCheckMode: "filename",
useFirstArtistOnly: false,
useSingleGenre: false,
embedGenre: false,
redownloadWithSuffix: false,
separator: "semicolon"
separator: "semicolon",
};
export const FONT_OPTIONS: {
value: FontFamily;
label: string;
fontFamily: string;
}[] = [
{ value: "bricolage-grotesque", label: "Bricolage Grotesque", fontFamily: '"Bricolage Grotesque", system-ui, sans-serif' },
{ value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' },
{ value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
{ value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
{ value: "google-sans", label: "Google Sans", fontFamily: '"Google Sans", system-ui, sans-serif' },
{ value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' },
{ value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' },
{ value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' },
{ value: "noto-sans", label: "Noto Sans", fontFamily: '"Noto Sans", system-ui, sans-serif' },
{ value: "nunito-sans", label: "Nunito Sans", fontFamily: '"Nunito Sans", system-ui, sans-serif' },
{ value: "outfit", label: "Outfit", fontFamily: '"Outfit", system-ui, sans-serif' },
{ value: "plus-jakarta-sans", label: "Plus Jakarta Sans", fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif' },
{ value: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' },
{ value: "public-sans", label: "Public Sans", fontFamily: '"Public Sans", system-ui, sans-serif' },
{ value: "raleway", label: "Raleway", fontFamily: '"Raleway", system-ui, sans-serif' },
{ value: "roboto", label: "Roboto", fontFamily: '"Roboto", system-ui, sans-serif' },
{ value: "space-grotesk", label: "Space Grotesk", fontFamily: '"Space Grotesk", system-ui, sans-serif' },
export const FONT_OPTIONS: FontOption[] = [
{
value: "bricolage-grotesque",
label: "Bricolage Grotesque",
fontFamily: '"Bricolage Grotesque", system-ui, sans-serif',
},
{
value: "dm-sans",
label: "DM Sans",
fontFamily: '"DM Sans", system-ui, sans-serif',
},
{
value: "figtree",
label: "Figtree",
fontFamily: '"Figtree", system-ui, sans-serif',
},
{
value: "geist-sans",
label: "Geist Sans",
fontFamily: '"Geist", system-ui, sans-serif',
},
{
value: "google-sans",
label: "Google Sans",
fontFamily: '"Google Sans", system-ui, sans-serif',
},
{
value: "inter",
label: "Inter",
fontFamily: '"Inter", system-ui, sans-serif',
},
{
value: "jetbrains-mono",
label: "JetBrains Mono",
fontFamily: '"JetBrains Mono", ui-monospace, monospace',
},
{
value: "manrope",
label: "Manrope",
fontFamily: '"Manrope", system-ui, sans-serif',
},
{
value: "noto-sans",
label: "Noto Sans",
fontFamily: '"Noto Sans", system-ui, sans-serif',
},
{
value: "nunito-sans",
label: "Nunito Sans",
fontFamily: '"Nunito Sans", system-ui, sans-serif',
},
{
value: "outfit",
label: "Outfit",
fontFamily: '"Outfit", system-ui, sans-serif',
},
{
value: "plus-jakarta-sans",
label: "Plus Jakarta Sans",
fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif',
},
{
value: "poppins",
label: "Poppins",
fontFamily: '"Poppins", system-ui, sans-serif',
},
{
value: "public-sans",
label: "Public Sans",
fontFamily: '"Public Sans", system-ui, sans-serif',
},
{
value: "raleway",
label: "Raleway",
fontFamily: '"Raleway", system-ui, sans-serif',
},
{
value: "roboto",
label: "Roboto",
fontFamily: '"Roboto", system-ui, sans-serif',
},
{
value: "space-grotesk",
label: "Space Grotesk",
fontFamily: '"Space Grotesk", system-ui, sans-serif',
},
];
export function applyFont(fontFamily: FontFamily): void {
const font = FONT_OPTIONS.find(f => f.value === fontFamily);
const BUILT_IN_FONT_VALUES = new Set(FONT_OPTIONS.map((font) => font.value));
const GOOGLE_FONT_LINK_ID_PREFIX = "spotiflac-custom-font-";
const GOOGLE_FONTS_CSS_HOST = "fonts.googleapis.com";
const GOOGLE_FONTS_SPECIMEN_HOST = "fonts.google.com";
const SETTINGS_KEY = "spotiflac-settings";
let cachedSettings: Settings | null = null;
type SettingsPayload = Partial<Settings> & {
darkMode?: boolean;
[key: string]: unknown;
};
const KNOWN_SETTINGS_KEYS = Object.keys(DEFAULT_SETTINGS) as Array<keyof Settings>;
function extractGoogleFontInputUrl(input: string): string {
const trimmed = input.trim();
const hrefMatch = trimmed.match(/\bhref=["']([^"']+)["']/i);
if (hrefMatch?.[1]) {
return hrefMatch[1];
}
const importMatch = trimmed.match(/@import\s+url\(["']?([^"')]+)["']?\)/i);
if (importMatch?.[1]) {
return importMatch[1];
}
return trimmed;
}
function coerceGoogleFontUrl(rawUrl: string): string {
const trimmed = rawUrl.trim();
if (/^https?:\/\//i.test(trimmed)) {
return trimmed;
}
if (/^(fonts\.googleapis\.com|fonts\.google\.com)\//i.test(trimmed)) {
return `https://${trimmed}`;
}
return trimmed;
}
function normalizeFontLabel(label: string): string {
return label.replace(/\+/g, " ").replace(/\s+/g, " ").trim();
}
function slugifyFontLabel(label: string): string {
return label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "font";
}
function toFontFamilyCss(label: string): string {
const escapedLabel = label.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return `"${escapedLabel}", system-ui, sans-serif`;
}
function buildGoogleFontsCssUrl(label: string): string {
const url = new URL("https://fonts.googleapis.com/css2");
url.searchParams.set("family", label);
url.searchParams.set("display", "swap");
return url.toString();
}
function extractSpecimenFontLabel(parsed: URL): string {
const segments = parsed.pathname.split("/").filter(Boolean);
const specimenIndex = segments.findIndex((segment) => segment.toLowerCase() === "specimen");
const specimenName = specimenIndex >= 0 ? segments[specimenIndex + 1] : "";
return normalizeFontLabel(decodeURIComponent(specimenName || ""));
}
function normalizeGoogleFontCssUrl(rawUrl: string): string | null {
try {
const parsed = new URL(coerceGoogleFontUrl(extractGoogleFontInputUrl(rawUrl)));
if (parsed.protocol !== "https:") {
return null;
}
if (parsed.hostname === GOOGLE_FONTS_SPECIMEN_HOST) {
const label = extractSpecimenFontLabel(parsed);
return label ? buildGoogleFontsCssUrl(label) : null;
}
if (parsed.hostname !== GOOGLE_FONTS_CSS_HOST ||
(parsed.pathname !== "/css" && parsed.pathname !== "/css2")) {
return null;
}
if (parsed.searchParams.getAll("family").length === 0) {
return null;
}
if (!parsed.searchParams.has("display")) {
parsed.searchParams.set("display", "swap");
}
return parsed.toString();
}
catch {
return null;
}
}
export function parseGoogleFontUrl(rawUrl: string): CustomFontOption | null {
const normalizedUrl = normalizeGoogleFontCssUrl(rawUrl);
if (!normalizedUrl) {
return null;
}
const parsed = new URL(normalizedUrl);
const family = parsed.searchParams.getAll("family")[0];
const label = normalizeFontLabel((family || "").split(":")[0] || "");
if (!label) {
return null;
}
return {
value: `custom-${slugifyFontLabel(label)}` as CustomFontFamily,
label,
fontFamily: toFontFamilyCss(label),
url: normalizedUrl,
};
}
function normalizeCustomFonts(customFonts: unknown): CustomFontOption[] {
if (!Array.isArray(customFonts)) {
return [];
}
const normalizedFonts: CustomFontOption[] = [];
const seenValues = new Set<string>();
const seenUrls = new Set<string>();
for (const item of customFonts) {
if (!item || typeof item !== "object") {
continue;
}
const rawUrl = (item as {
url?: unknown;
}).url;
if (typeof rawUrl !== "string") {
continue;
}
const parsed = parseGoogleFontUrl(rawUrl);
if (!parsed || seenValues.has(parsed.value) || seenUrls.has(parsed.url)) {
continue;
}
seenValues.add(parsed.value);
seenUrls.add(parsed.url);
normalizedFonts.push(parsed);
}
return normalizedFonts;
}
function normalizeFontFamily(fontFamily: unknown, customFonts: CustomFontOption[]): FontFamily {
if (typeof fontFamily !== "string") {
return DEFAULT_SETTINGS.fontFamily;
}
if (BUILT_IN_FONT_VALUES.has(fontFamily as BuiltInFontFamily)) {
return fontFamily as BuiltInFontFamily;
}
const customFont = customFonts.find((font) => font.value === fontFamily);
return customFont ? customFont.value : DEFAULT_SETTINGS.fontFamily;
}
export function getFontOptions(customFonts: CustomFontOption[] = []): FontOption[] {
return [...FONT_OPTIONS, ...normalizeCustomFonts(customFonts)];
}
export function loadGoogleFontUrl(url: string, id = `${GOOGLE_FONT_LINK_ID_PREFIX}preview`): void {
const normalizedUrl = normalizeGoogleFontCssUrl(url);
if (!normalizedUrl) {
return;
}
let link = document.getElementById(id) as HTMLLinkElement | null;
if (!link) {
link = document.createElement("link");
link.id = id;
link.rel = "stylesheet";
document.head.appendChild(link);
}
if (link.href !== normalizedUrl) {
link.href = normalizedUrl;
}
}
function loadCustomFontStylesheets(customFonts: CustomFontOption[]): void {
for (const font of normalizeCustomFonts(customFonts)) {
loadGoogleFontUrl(font.url, `${GOOGLE_FONT_LINK_ID_PREFIX}${font.value}`);
}
}
export function applyFont(fontFamily: FontFamily, customFonts: CustomFontOption[] = []): void {
const fontOptions = getFontOptions(customFonts);
loadCustomFontStylesheets(customFonts);
const font = fontOptions.find((option) => option.value === fontFamily) ||
FONT_OPTIONS.find((option) => option.value === DEFAULT_SETTINGS.fontFamily);
if (font) {
document.documentElement.style.setProperty('--font-sans', font.fontFamily);
document.documentElement.style.setProperty("--font-sans", font.fontFamily);
document.body.style.fontFamily = font.fontFamily;
}
}
async function persistCustomFontsInternal(customFonts: CustomFontOption[]): Promise<CustomFontOption[]> {
const normalizedFonts = normalizeCustomFonts(customFonts);
await SaveFontsToBackend(normalizedFonts as unknown as Array<Record<string, unknown>>);
if (cachedSettings) {
cachedSettings = toNormalizedSettings({
...cachedSettings,
customFonts: normalizedFonts,
});
localStorage.setItem(SETTINGS_KEY, JSON.stringify(cachedSettings));
window.dispatchEvent(new CustomEvent("settingsUpdated", { detail: cachedSettings }));
}
return normalizedFonts;
}
async function loadStoredCustomFonts(fallbackFonts?: unknown): Promise<CustomFontOption[]> {
try {
const storedFonts = await LoadFontsFromBackend();
if (storedFonts !== null) {
return normalizeCustomFonts(storedFonts);
}
}
catch (error) {
console.error("Failed to load custom fonts:", error);
}
const migratedFonts = normalizeCustomFonts(fallbackFonts);
if (migratedFonts.length > 0) {
try {
return await persistCustomFontsInternal(migratedFonts);
}
catch (error) {
console.error("Failed to migrate custom fonts:", error);
}
}
return migratedFonts;
}
export async function loadCustomFonts(): Promise<CustomFontOption[]> {
return loadStoredCustomFonts(getSettings().customFonts);
}
export async function saveCustomFonts(customFonts: CustomFontOption[]): Promise<CustomFontOption[]> {
return persistCustomFontsInternal(customFonts);
}
function keepKnownSettings(settings: SettingsPayload): SettingsPayload {
const normalized: Record<string, unknown> = {};
for (const key of KNOWN_SETTINGS_KEYS) {
if (key in settings) {
normalized[key] = settings[key];
}
}
return normalized as SettingsPayload;
}
function normalizePreviewVolume(volume: unknown): number {
const parsed = typeof volume === "number"
? volume
: typeof volume === "string"
? Number.parseFloat(volume)
: Number.NaN;
if (!Number.isFinite(parsed)) {
return DEFAULT_SETTINGS.previewVolume;
}
return Math.min(100, Math.max(0, Math.round(parsed)));
}
function normalizeCustomTidalApi(value: unknown): string {
return typeof value === "string"
? value.trim().replace(/\/+$/g, "")
: "";
}
export function hasConfiguredCustomTidalApi(value: unknown): boolean {
return normalizeCustomTidalApi(value).startsWith("https://");
}
export function sanitizeAutoOrder(order: unknown, allowTidal: boolean): string {
const allowedServices = allowTidal
? new Set(["tidal", "qobuz", "amazon"])
: new Set(["qobuz", "amazon"]);
const fallbackOrder = allowTidal ? "tidal-qobuz-amazon" : "qobuz-amazon";
if (typeof order !== "string") {
return fallbackOrder;
}
const normalized = order
.split("-")
.map((part) => part.trim().toLowerCase())
.filter((part, index, parts) => part !== "" && allowedServices.has(part) && parts.indexOf(part) === index);
return normalized.length >= 2 ? normalized.join("-") : fallbackOrder;
}
function normalizeDownloader(value: unknown, allowTidal: boolean): Settings["downloader"] {
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
if (normalized === "tidal") {
return allowTidal ? "tidal" : "auto";
}
if (normalized === "qobuz" || normalized === "amazon" || normalized === "auto") {
return normalized;
}
return DEFAULT_SETTINGS.downloader;
}
function normalizeExistingFileCheckMode(mode: unknown): ExistingFileCheckMode {
switch (typeof mode === "string" ? mode.trim().toLowerCase() : "") {
case "isrc":
case "upc":
return "isrc";
default:
return "filename";
}
}
function normalizeSettingsPayload(settings: SettingsPayload): SettingsPayload {
const normalized: SettingsPayload = { ...settings };
if ("darkMode" in normalized && !("themeMode" in normalized)) {
normalized.themeMode = normalized.darkMode ? "dark" : "light";
delete normalized.darkMode;
}
if (!("folderPreset" in normalized) &&
("artistSubfolder" in normalized || "albumSubfolder" in normalized)) {
const hasArtist = Boolean(normalized.artistSubfolder);
const hasAlbum = Boolean(normalized.albumSubfolder);
if (hasArtist && hasAlbum) {
normalized.folderPreset = "artist-album";
normalized.folderTemplate = "{artist}/{album}";
}
else if (hasArtist) {
normalized.folderPreset = "artist";
normalized.folderTemplate = "{artist}";
}
else if (hasAlbum) {
normalized.folderPreset = "album";
normalized.folderTemplate = "{album}";
}
else {
normalized.folderPreset = "none";
normalized.folderTemplate = "";
}
}
if (!("filenamePreset" in normalized) && "filenameFormat" in normalized) {
const format = normalized.filenameFormat;
if (format === "title-artist") {
normalized.filenamePreset = "artist-title";
normalized.filenameTemplate = "{artist} - {title}";
}
else if (format === "artist-title") {
normalized.filenamePreset = "artist-title";
normalized.filenameTemplate = "{artist} - {title}";
}
else {
normalized.filenamePreset = "title";
normalized.filenameTemplate = "{title}";
}
}
delete normalized.tidalVariant;
if (!("tidalQuality" in normalized)) {
normalized.tidalQuality = "LOSSLESS";
}
if (!("qobuzQuality" in normalized)) {
normalized.qobuzQuality = "6";
}
if (!("amazonQuality" in normalized)) {
normalized.amazonQuality = "original";
}
if (!("autoOrder" in normalized)) {
normalized.autoOrder = DEFAULT_SETTINGS.autoOrder;
}
if (!("autoQuality" in normalized)) {
normalized.autoQuality = "16";
}
normalized.customTidalApi = normalizeCustomTidalApi(normalized.customTidalApi);
const allowTidal = hasConfiguredCustomTidalApi(normalized.customTidalApi);
normalized.downloader = normalizeDownloader(normalized.downloader, allowTidal);
normalized.autoOrder = sanitizeAutoOrder(normalized.autoOrder, allowTidal);
if (!("allowFallback" in normalized)) {
normalized.allowFallback = true;
}
if (!("linkResolver" in normalized)) {
normalized.linkResolver = "songlink";
}
if (!("allowResolverFallback" in normalized)) {
normalized.allowResolverFallback = true;
}
if (!("createPlaylistFolder" in normalized)) {
normalized.createPlaylistFolder = true;
}
if (!("playlistOwnerFolderName" in normalized)) {
normalized.playlistOwnerFolderName = false;
}
if (!("createM3u8File" in normalized)) {
normalized.createM3u8File = false;
}
normalized.previewVolume = normalizePreviewVolume(normalized.previewVolume);
normalized.existingFileCheckMode = normalizeExistingFileCheckMode(normalized.existingFileCheckMode);
if (!("useFirstArtistOnly" in normalized)) {
normalized.useFirstArtistOnly = false;
}
if (!("useSingleGenre" in normalized)) {
normalized.useSingleGenre = false;
}
if (!("embedGenre" in normalized)) {
normalized.embedGenre = false;
}
if (!("separator" in normalized)) {
normalized.separator = "semicolon";
}
if (!("redownloadWithSuffix" in normalized)) {
normalized.redownloadWithSuffix = false;
}
normalized.operatingSystem = detectOS();
const normalizedCustomFonts = normalizeCustomFonts(normalized.customFonts);
normalized.customFonts = normalizedCustomFonts;
normalized.fontFamily = normalizeFontFamily(normalized.fontFamily, normalizedCustomFonts);
return normalized;
}
function toNormalizedSettings(settings: SettingsPayload): Settings {
return {
...DEFAULT_SETTINGS,
...keepKnownSettings(normalizeSettingsPayload(settings)),
} as Settings;
}
async function persistSettingsInternal(settings: Settings, notify = true): Promise<void> {
cachedSettings = settings;
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
const settingsForBackend = { ...settings } as Record<string, unknown>;
delete settingsForBackend.customFonts;
await SaveToBackend(settingsForBackend);
if (notify) {
window.dispatchEvent(new CustomEvent("settingsUpdated", { detail: settings }));
}
}
async function fetchDefaultPath(): Promise<string> {
try {
const data = await GetDefaults();
@@ -167,90 +686,11 @@ async function fetchDefaultPath(): Promise<string> {
return "";
}
}
const SETTINGS_KEY = "spotiflac-settings";
let cachedSettings: Settings | null = null;
function getSettingsFromLocalStorage(): Settings {
try {
const stored = localStorage.getItem(SETTINGS_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if ('darkMode' in parsed && !('themeMode' in parsed)) {
parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
delete parsed.darkMode;
}
if (!('folderPreset' in parsed) && ('artistSubfolder' in parsed || 'albumSubfolder' in parsed)) {
const hasArtist = parsed.artistSubfolder;
const hasAlbum = parsed.albumSubfolder;
if (hasArtist && hasAlbum) {
parsed.folderPreset = "artist-album";
parsed.folderTemplate = "{artist}/{album}";
}
else if (hasArtist) {
parsed.folderPreset = "artist";
parsed.folderTemplate = "{artist}";
}
else if (hasAlbum) {
parsed.folderPreset = "album";
parsed.folderTemplate = "{album}";
}
else {
parsed.folderPreset = "none";
parsed.folderTemplate = "";
}
}
if (!('filenamePreset' in parsed) && 'filenameFormat' in parsed) {
const format = parsed.filenameFormat;
if (format === "title-artist") {
parsed.filenamePreset = "artist-title";
parsed.filenameTemplate = "{artist} - {title}";
}
else if (format === "artist-title") {
parsed.filenamePreset = "artist-title";
parsed.filenameTemplate = "{artist} - {title}";
}
else {
parsed.filenamePreset = "title";
parsed.filenameTemplate = "{title}";
}
}
parsed.operatingSystem = detectOS();
if (!('tidalQuality' in parsed)) {
parsed.tidalQuality = "LOSSLESS";
}
if (!('tidalVariant' in parsed)) {
parsed.tidalVariant = "tidal";
}
if (!('qobuzQuality' in parsed)) {
parsed.qobuzQuality = "6";
}
if (!('amazonQuality' in parsed)) {
parsed.amazonQuality = "original";
}
if (!('autoOrder' in parsed)) {
parsed.autoOrder = "tidal-qobuz-amazon";
}
if (!('autoQuality' in parsed)) {
parsed.autoQuality = "16";
}
if (!('allowFallback' in parsed)) {
parsed.allowFallback = true;
}
if (!('linkResolver' in parsed)) {
parsed.linkResolver = "songlink";
}
if (!('allowResolverFallback' in parsed)) {
parsed.allowResolverFallback = true;
}
if (!('playlistOwnerFolderName' in parsed)) {
parsed.playlistOwnerFolderName = false;
}
if (!('separator' in parsed)) {
parsed.separator = "semicolon";
}
if (!('redownloadWithSuffix' in parsed)) {
parsed.redownloadWithSuffix = false;
}
return { ...DEFAULT_SETTINGS, ...parsed };
return toNormalizedSettings(JSON.parse(stored) as SettingsPayload);
}
}
catch (error) {
@@ -259,108 +699,25 @@ function getSettingsFromLocalStorage(): Settings {
return DEFAULT_SETTINGS;
}
export function getSettings(): Settings {
if (cachedSettings)
if (cachedSettings) {
return cachedSettings;
}
return getSettingsFromLocalStorage();
}
export async function loadSettings(): Promise<Settings> {
try {
const backendSettings = await LoadSettings();
if (backendSettings) {
const parsed = backendSettings as any;
if ('darkMode' in parsed && !('themeMode' in parsed)) {
parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
delete parsed.darkMode;
const parsed = backendSettings as SettingsPayload;
const customFonts = await loadStoredCustomFonts(parsed.customFonts);
cachedSettings = toNormalizedSettings({
...parsed,
customFonts,
});
if ("customFonts" in parsed) {
await persistSettingsInternal(cachedSettings, false);
}
if (!('folderPreset' in parsed) && ('artistSubfolder' in parsed || 'albumSubfolder' in parsed)) {
const hasArtist = parsed.artistSubfolder;
const hasAlbum = parsed.albumSubfolder;
if (hasArtist && hasAlbum) {
parsed.folderPreset = "artist-album";
parsed.folderTemplate = "{artist}/{album}";
}
else if (hasArtist) {
parsed.folderPreset = "artist";
parsed.folderTemplate = "{artist}";
}
else if (hasAlbum) {
parsed.folderPreset = "album";
parsed.folderTemplate = "{album}";
}
else {
parsed.folderPreset = "none";
parsed.folderTemplate = "";
}
}
if (!('filenamePreset' in parsed) && 'filenameFormat' in parsed) {
const format = parsed.filenameFormat;
if (format === "title-artist") {
parsed.filenamePreset = "artist-title";
parsed.filenameTemplate = "{artist} - {title}";
}
else if (format === "artist-title") {
parsed.filenamePreset = "artist-title";
parsed.filenameTemplate = "{artist} - {title}";
}
else {
parsed.filenamePreset = "title";
parsed.filenameTemplate = "{title}";
}
}
parsed.operatingSystem = detectOS();
if (!('tidalQuality' in parsed)) {
parsed.tidalQuality = "LOSSLESS";
}
if (!('tidalVariant' in parsed)) {
parsed.tidalVariant = "tidal";
}
if (!('qobuzQuality' in parsed)) {
parsed.qobuzQuality = "6";
}
if (!('amazonQuality' in parsed)) {
parsed.amazonQuality = "original";
}
if (!('autoOrder' in parsed)) {
parsed.autoOrder = "tidal-qobuz-amazon";
}
if (!('autoQuality' in parsed)) {
parsed.autoQuality = "16";
}
if (!('allowFallback' in parsed)) {
parsed.allowFallback = true;
}
if (!('linkResolver' in parsed)) {
parsed.linkResolver = "songlink";
}
if (!('allowResolverFallback' in parsed)) {
parsed.allowResolverFallback = true;
}
if (!('createPlaylistFolder' in parsed)) {
parsed.createPlaylistFolder = true;
}
if (!('playlistOwnerFolderName' in parsed)) {
parsed.playlistOwnerFolderName = false;
}
if (!('createM3u8File' in parsed)) {
parsed.createM3u8File = false;
}
if (!('useFirstArtistOnly' in parsed)) {
parsed.useFirstArtistOnly = false;
}
if (!('useSingleGenre' in parsed)) {
parsed.useSingleGenre = false;
}
if (!('embedGenre' in parsed)) {
parsed.embedGenre = false;
}
if (!('separator' in parsed)) {
parsed.separator = "semicolon";
}
if (!('redownloadWithSuffix' in parsed)) {
parsed.redownloadWithSuffix = false;
}
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
return cachedSettings!;
return cachedSettings;
}
}
catch (error) {
@@ -368,12 +725,19 @@ export async function loadSettings(): Promise<Settings> {
}
const local = getSettingsFromLocalStorage();
try {
await SaveToBackend(local as any);
cachedSettings = local;
const customFonts = await loadStoredCustomFonts(local.customFonts);
const localWithFonts = toNormalizedSettings({
...local,
customFonts,
});
await persistSettingsInternal(localWithFonts, false);
cachedSettings = localWithFonts;
return localWithFonts;
}
catch (error) {
console.error("Failed to migrate settings to backend:", error);
}
cachedSettings = local;
return local;
}
export interface TemplateData {
@@ -389,8 +753,9 @@ export interface TemplateData {
playlist?: string;
}
export function parseTemplate(template: string, data: TemplateData): string {
if (!template)
if (!template) {
return "";
}
let result = template;
result = result.replace(/\{title\}/g, data.title || "Unknown Title");
result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist");
@@ -414,10 +779,8 @@ export async function getSettingsWithDefaults(): Promise<Settings> {
}
export async function saveSettings(settings: Settings): Promise<void> {
try {
cachedSettings = settings;
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
await SaveToBackend(settings as any);
window.dispatchEvent(new CustomEvent('settingsUpdated', { detail: settings }));
const normalizedSettings = toNormalizedSettings(settings as SettingsPayload);
await persistSettingsInternal(normalizedSettings);
}
catch (error) {
console.error("Failed to save settings:", error);
@@ -431,7 +794,12 @@ export async function updateSettings(partial: Partial<Settings>): Promise<Settin
}
export async function resetToDefaultSettings(): Promise<Settings> {
const defaultPath = await fetchDefaultPath();
const defaultSettings = { ...DEFAULT_SETTINGS, downloadPath: defaultPath };
const customFonts = await loadCustomFonts();
const defaultSettings = {
...DEFAULT_SETTINGS,
downloadPath: defaultPath,
customFonts,
};
await saveSettings(defaultSettings);
return defaultSettings;
}
-1
View File
@@ -120,7 +120,6 @@ export interface DownloadRequest {
release_date?: string;
cover_url?: string;
tidal_api_url?: string;
tidal_variant?: "tidal" | "alt";
output_dir?: string;
audio_format?: string;
folder_name?: string;
-142
View File
@@ -1,142 +0,0 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {backend} from '../models';
import {main} from '../models';
export function AddFetchHistory(arg1:backend.FetchHistoryItem):Promise<void>;
export function AddToDownloadQueue(arg1:string,arg2:string,arg3:string,arg4:string):Promise<string>;
export function CancelAllQueuedItems():Promise<void>;
export function CheckAPIStatus(arg1:string,arg2:string):Promise<boolean>;
export function CheckFFmpegInstalled():Promise<boolean>;
export function CheckFilesExistence(arg1:string,arg2:string,arg3:Array<main.CheckFileExistenceRequest>):Promise<Array<main.CheckFileExistenceResult>>;
export function CheckTrackAvailability(arg1:string):Promise<string>;
export function ClearAllDownloads():Promise<void>;
export function ClearCompletedDownloads():Promise<void>;
export function ClearDownloadHistory():Promise<void>;
export function ClearFetchHistory():Promise<void>;
export function ClearFetchHistoryByType(arg1:string):Promise<void>;
export function ConvertAudio(arg1:main.ConvertAudioRequest):Promise<Array<backend.ConvertAudioResult>>;
export function CreateM3U8File(arg1:string,arg2:string,arg3:Array<string>):Promise<void>;
export function DecodeAudioForAnalysis(arg1:string):Promise<backend.AnalysisDecodeResponse>;
export function DeleteDownloadHistoryItem(arg1:string):Promise<void>;
export function DeleteFetchHistoryItem(arg1:string):Promise<void>;
export function DownloadAvatar(arg1:main.AvatarDownloadRequest):Promise<backend.AvatarDownloadResponse>;
export function DownloadCover(arg1:main.CoverDownloadRequest):Promise<backend.CoverDownloadResponse>;
export function DownloadFFmpeg():Promise<main.DownloadFFmpegResponse>;
export function DownloadGalleryImage(arg1:main.GalleryImageDownloadRequest):Promise<backend.GalleryImageDownloadResponse>;
export function DownloadHeader(arg1:main.HeaderDownloadRequest):Promise<backend.HeaderDownloadResponse>;
export function DownloadLyrics(arg1:main.LyricsDownloadRequest):Promise<backend.LyricsDownloadResponse>;
export function DownloadTrack(arg1:main.DownloadRequest):Promise<main.DownloadResponse>;
export function ExportFailedDownloads():Promise<string>;
export function GetBrewPath():Promise<string>;
export function GetConfigPath():Promise<string>;
export function GetCurrentIPInfo():Promise<string>;
export function GetDefaults():Promise<Record<string, string>>;
export function GetDownloadHistory():Promise<Array<backend.HistoryItem>>;
export function GetDownloadProgress():Promise<backend.ProgressInfo>;
export function GetDownloadQueue():Promise<backend.DownloadQueueInfo>;
export function GetFetchHistory():Promise<Array<backend.FetchHistoryItem>>;
export function GetFileSizes(arg1:Array<string>):Promise<Record<string, number>>;
export function GetFlacInfoBatch(arg1:Array<string>):Promise<Array<backend.FlacInfo>>;
export function GetPreviewURL(arg1:string):Promise<string>;
export function GetRecentFetches():Promise<string>;
export function GetSpotifyMetadata(arg1:main.SpotifyMetadataRequest):Promise<string>;
export function GetStreamingURLs(arg1:string,arg2:string):Promise<string>;
export function GetTrackISRC(arg1:string):Promise<string>;
export function InstallFFmpegWithBrew():Promise<main.InstallFFmpegWithBrewResponse>;
export function IsBrewFFmpegInstalled():Promise<boolean>;
export function IsFFmpegInstalled():Promise<boolean>;
export function IsFFprobeInstalled():Promise<boolean>;
export function ListAudioFilesInDir(arg1:string):Promise<Array<backend.FileInfo>>;
export function ListDirectoryFiles(arg1:string):Promise<Array<backend.FileInfo>>;
export function LoadSettings():Promise<Record<string, any>>;
export function MarkDownloadItemFailed(arg1:string,arg2:string):Promise<void>;
export function OpenConfigFolder():Promise<void>;
export function OpenFolder(arg1:string):Promise<void>;
export function PreviewRenameFiles(arg1:Array<string>,arg2:string):Promise<Array<backend.RenamePreview>>;
export function Quit():Promise<void>;
export function ReadFileAsBase64(arg1:string):Promise<string>;
export function ReadFileMetadata(arg1:string):Promise<backend.AudioMetadata>;
export function ReadImageAsBase64(arg1:string):Promise<string>;
export function ReadTextFile(arg1:string):Promise<string>;
export function RenameFileTo(arg1:string,arg2:string):Promise<void>;
export function RenameFilesByMetadata(arg1:Array<string>,arg2:string):Promise<Array<backend.RenameResult>>;
export function ResampleAudio(arg1:main.ResampleAudioRequest):Promise<Array<backend.ResampleResult>>;
export function SaveRecentFetches(arg1:string):Promise<void>;
export function SaveSettings(arg1:Record<string, any>):Promise<void>;
export function SaveSpectrumImage(arg1:string,arg2:string):Promise<string>;
export function SearchSpotify(arg1:main.SpotifySearchRequest):Promise<backend.SearchResponse>;
export function SearchSpotifyByType(arg1:main.SpotifySearchByTypeRequest):Promise<Array<backend.SearchResult>>;
export function SelectAudioFiles():Promise<Array<string>>;
export function SelectFile():Promise<string>;
export function SelectFolder(arg1:string):Promise<string>;
export function SelectImageVideo():Promise<Array<string>>;
export function SkipDownloadItem(arg1:string,arg2:string):Promise<void>;
-279
View File
@@ -1,279 +0,0 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function AddFetchHistory(arg1) {
return window['go']['main']['App']['AddFetchHistory'](arg1);
}
export function AddToDownloadQueue(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['AddToDownloadQueue'](arg1, arg2, arg3, arg4);
}
export function CancelAllQueuedItems() {
return window['go']['main']['App']['CancelAllQueuedItems']();
}
export function CheckAPIStatus(arg1, arg2) {
return window['go']['main']['App']['CheckAPIStatus'](arg1, arg2);
}
export function CheckFFmpegInstalled() {
return window['go']['main']['App']['CheckFFmpegInstalled']();
}
export function CheckFilesExistence(arg1, arg2, arg3) {
return window['go']['main']['App']['CheckFilesExistence'](arg1, arg2, arg3);
}
export function CheckTrackAvailability(arg1) {
return window['go']['main']['App']['CheckTrackAvailability'](arg1);
}
export function ClearAllDownloads() {
return window['go']['main']['App']['ClearAllDownloads']();
}
export function ClearCompletedDownloads() {
return window['go']['main']['App']['ClearCompletedDownloads']();
}
export function ClearDownloadHistory() {
return window['go']['main']['App']['ClearDownloadHistory']();
}
export function ClearFetchHistory() {
return window['go']['main']['App']['ClearFetchHistory']();
}
export function ClearFetchHistoryByType(arg1) {
return window['go']['main']['App']['ClearFetchHistoryByType'](arg1);
}
export function ConvertAudio(arg1) {
return window['go']['main']['App']['ConvertAudio'](arg1);
}
export function CreateM3U8File(arg1, arg2, arg3) {
return window['go']['main']['App']['CreateM3U8File'](arg1, arg2, arg3);
}
export function DecodeAudioForAnalysis(arg1) {
return window['go']['main']['App']['DecodeAudioForAnalysis'](arg1);
}
export function DeleteDownloadHistoryItem(arg1) {
return window['go']['main']['App']['DeleteDownloadHistoryItem'](arg1);
}
export function DeleteFetchHistoryItem(arg1) {
return window['go']['main']['App']['DeleteFetchHistoryItem'](arg1);
}
export function DownloadAvatar(arg1) {
return window['go']['main']['App']['DownloadAvatar'](arg1);
}
export function DownloadCover(arg1) {
return window['go']['main']['App']['DownloadCover'](arg1);
}
export function DownloadFFmpeg() {
return window['go']['main']['App']['DownloadFFmpeg']();
}
export function DownloadGalleryImage(arg1) {
return window['go']['main']['App']['DownloadGalleryImage'](arg1);
}
export function DownloadHeader(arg1) {
return window['go']['main']['App']['DownloadHeader'](arg1);
}
export function DownloadLyrics(arg1) {
return window['go']['main']['App']['DownloadLyrics'](arg1);
}
export function DownloadTrack(arg1) {
return window['go']['main']['App']['DownloadTrack'](arg1);
}
export function ExportFailedDownloads() {
return window['go']['main']['App']['ExportFailedDownloads']();
}
export function GetBrewPath() {
return window['go']['main']['App']['GetBrewPath']();
}
export function GetConfigPath() {
return window['go']['main']['App']['GetConfigPath']();
}
export function GetCurrentIPInfo() {
return window['go']['main']['App']['GetCurrentIPInfo']();
}
export function GetDefaults() {
return window['go']['main']['App']['GetDefaults']();
}
export function GetDownloadHistory() {
return window['go']['main']['App']['GetDownloadHistory']();
}
export function GetDownloadProgress() {
return window['go']['main']['App']['GetDownloadProgress']();
}
export function GetDownloadQueue() {
return window['go']['main']['App']['GetDownloadQueue']();
}
export function GetFetchHistory() {
return window['go']['main']['App']['GetFetchHistory']();
}
export function GetFileSizes(arg1) {
return window['go']['main']['App']['GetFileSizes'](arg1);
}
export function GetFlacInfoBatch(arg1) {
return window['go']['main']['App']['GetFlacInfoBatch'](arg1);
}
export function GetPreviewURL(arg1) {
return window['go']['main']['App']['GetPreviewURL'](arg1);
}
export function GetRecentFetches() {
return window['go']['main']['App']['GetRecentFetches']();
}
export function GetSpotifyMetadata(arg1) {
return window['go']['main']['App']['GetSpotifyMetadata'](arg1);
}
export function GetStreamingURLs(arg1, arg2) {
return window['go']['main']['App']['GetStreamingURLs'](arg1, arg2);
}
export function GetTrackISRC(arg1) {
return window['go']['main']['App']['GetTrackISRC'](arg1);
}
export function InstallFFmpegWithBrew() {
return window['go']['main']['App']['InstallFFmpegWithBrew']();
}
export function IsBrewFFmpegInstalled() {
return window['go']['main']['App']['IsBrewFFmpegInstalled']();
}
export function IsFFmpegInstalled() {
return window['go']['main']['App']['IsFFmpegInstalled']();
}
export function IsFFprobeInstalled() {
return window['go']['main']['App']['IsFFprobeInstalled']();
}
export function ListAudioFilesInDir(arg1) {
return window['go']['main']['App']['ListAudioFilesInDir'](arg1);
}
export function ListDirectoryFiles(arg1) {
return window['go']['main']['App']['ListDirectoryFiles'](arg1);
}
export function LoadSettings() {
return window['go']['main']['App']['LoadSettings']();
}
export function MarkDownloadItemFailed(arg1, arg2) {
return window['go']['main']['App']['MarkDownloadItemFailed'](arg1, arg2);
}
export function OpenConfigFolder() {
return window['go']['main']['App']['OpenConfigFolder']();
}
export function OpenFolder(arg1) {
return window['go']['main']['App']['OpenFolder'](arg1);
}
export function PreviewRenameFiles(arg1, arg2) {
return window['go']['main']['App']['PreviewRenameFiles'](arg1, arg2);
}
export function Quit() {
return window['go']['main']['App']['Quit']();
}
export function ReadFileAsBase64(arg1) {
return window['go']['main']['App']['ReadFileAsBase64'](arg1);
}
export function ReadFileMetadata(arg1) {
return window['go']['main']['App']['ReadFileMetadata'](arg1);
}
export function ReadImageAsBase64(arg1) {
return window['go']['main']['App']['ReadImageAsBase64'](arg1);
}
export function ReadTextFile(arg1) {
return window['go']['main']['App']['ReadTextFile'](arg1);
}
export function RenameFileTo(arg1, arg2) {
return window['go']['main']['App']['RenameFileTo'](arg1, arg2);
}
export function RenameFilesByMetadata(arg1, arg2) {
return window['go']['main']['App']['RenameFilesByMetadata'](arg1, arg2);
}
export function ResampleAudio(arg1) {
return window['go']['main']['App']['ResampleAudio'](arg1);
}
export function SaveRecentFetches(arg1) {
return window['go']['main']['App']['SaveRecentFetches'](arg1);
}
export function SaveSettings(arg1) {
return window['go']['main']['App']['SaveSettings'](arg1);
}
export function SaveSpectrumImage(arg1, arg2) {
return window['go']['main']['App']['SaveSpectrumImage'](arg1, arg2);
}
export function SearchSpotify(arg1) {
return window['go']['main']['App']['SearchSpotify'](arg1);
}
export function SearchSpotifyByType(arg1) {
return window['go']['main']['App']['SearchSpotifyByType'](arg1);
}
export function SelectAudioFiles() {
return window['go']['main']['App']['SelectAudioFiles']();
}
export function SelectFile() {
return window['go']['main']['App']['SelectFile']();
}
export function SelectFolder(arg1) {
return window['go']['main']['App']['SelectFolder'](arg1);
}
export function SelectImageVideo() {
return window['go']['main']['App']['SelectImageVideo']();
}
export function SkipDownloadItem(arg1, arg2) {
return window['go']['main']['App']['SkipDownloadItem'](arg1, arg2);
}
+2 -1
View File
@@ -9,13 +9,14 @@ require (
github.com/go-flac/go-flac v1.0.0
github.com/pquerna/otp v1.5.0
github.com/ulikunitz/xz v0.5.15
github.com/wailsapp/wails/v2 v2.11.0
github.com/wailsapp/wails/v2 v2.12.0
go.etcd.io/bbolt v1.4.3
golang.org/x/image v0.12.0
golang.org/x/text v0.31.0
)
require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
+4 -2
View File
@@ -1,3 +1,5 @@
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
@@ -73,8 +75,8 @@ github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
+1 -1
View File
@@ -12,7 +12,7 @@
},
"info": {
"productName": "SpotiFLAC",
"productVersion": "7.1.5",
"productVersion": "7.1.7",
"copyright": "© 2026 afkarxyz"
},
"wailsjsdir": "./frontend",