Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f78f7e7c7 | |||
| 7c52c2d9b4 | |||
| a24ca370eb | |||
| 3af9327a3d | |||
| a3e780587b | |||
| 043f3f07f3 | |||
| bcea7a00bd | |||
| e74808fb07 | |||
| 17a75ea278 | |||
| d6907641ed | |||
| e9b6b02db1 | |||
| a9c52e7b6d | |||
| ce1e6cc65a | |||
| f75081780e | |||
| c0c1348c3f | |||
| f123caf5b0 | |||
| 4c5bba73ce | |||
| 1858fd6f12 | |||
| 42d25abe0c | |||
| 927aad30e7 | |||
| 7320cfb6ca | |||
| d85d3174e9 | |||
| eda188d4b0 | |||
| 7997f7e264 | |||
| e23fa2a48e | |||
| 5a3f819cef | |||
| 66e3f0e572 | |||
| 2684bc54bd | |||
| db8f82aa17 | |||
| 7792a69d33 | |||
| e79622751d | |||
| 1b00badd93 | |||
| 24d640443a | |||
| 967feb93e1 | |||
| 475596d934 | |||
| 0d42bc3877 | |||
| 7f12b76fd9 | |||
| 99f5e4e8b3 | |||
| 2d2ceac569 | |||
| 5fa9da8e23 | |||
| 0237895603 | |||
| fc5bda3b26 | |||
| af72ca0d01 | |||
| 42278aa1f3 | |||
| 1128b0245f | |||
| 460405a437 | |||
| 4b3bf1cf48 | |||
| 41eda2d230 | |||
| 78caf6cc61 | |||
| 9314b8ec99 | |||
| cfcb890469 | |||
| e74ac07afc | |||
| 0475529535 | |||
| 264b474903 | |||
| 6066278fe6 | |||
| cf36d28444 | |||
| 7ce66b4732 | |||
| b96fc8d96c | |||
| 6de2bae67b | |||
| 3e04868746 | |||
| e3f8f7be0a | |||
| 5ebd28982b | |||
| f8ef1180f6 | |||
| 386c541658 | |||
| d60a068cab | |||
| 78adc15be3 | |||
| 724520f51f | |||
| c342c3f9ee | |||
| 8919b9a77a | |||
| 528bf65771 | |||
| 0e6b6f9d39 | |||
| 45885e1856 | |||
| b31e1fe565 | |||
| 4e7fc468cd | |||
| d8722c58dc | |||
| dd67b54ea9 | |||
| cbca6c799f |
+1
-2
@@ -1,2 +1 @@
|
|||||||
ko_fi: afkarxyz
|
ko_fi: afkarxyz
|
||||||
patreon: afkarxyz
|
|
||||||
@@ -24,12 +24,14 @@ Get Spotify tracks in true Lossless from Tidal, Qobuz, Amazon Music, Deezer & Ap
|
|||||||
|
|
||||||
Download Spotify Tracks, Albums, Playlists & Discography as MP3/OGG/Opus.
|
Download Spotify Tracks, Albums, Playlists & Discography as MP3/OGG/Opus.
|
||||||
|
|
||||||
## Related projects
|
|
||||||
|
|
||||||
### [SpotiFLAC (Mobile)](https://github.com/zarzet/SpotiFLAC-Mobile)
|
### [SpotiFLAC (Mobile)](https://github.com/zarzet/SpotiFLAC-Mobile)
|
||||||
|
|
||||||
SpotiFLAC for Android & iOS — maintained by [@zarzet](https://github.com/zarzet)
|
SpotiFLAC for Android & iOS — maintained by [@zarzet](https://github.com/zarzet)
|
||||||
|
|
||||||
|
### [SpotiFLAC (CLI)](https://github.com/Nizarberyan/SpotiFLAC)
|
||||||
|
|
||||||
|
SpotiFLAC for command-line environments — maintained by [@Nizarberyan](https://github.com/Nizarberyan)
|
||||||
|
|
||||||
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
||||||
|
|
||||||
SpotiFLAC Python library for SpotiFLAC integration — maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu)
|
SpotiFLAC Python library for SpotiFLAC integration — maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu)
|
||||||
@@ -106,7 +108,7 @@ The software is provided "as is", without warranty of any kind. The author assum
|
|||||||
|
|
||||||
## API Credits
|
## API Credits
|
||||||
|
|
||||||
[MusicBrainz](https://musicbrainz.org) · [LRCLIB](https://lrclib.net) · [Songlink/Odesli](https://song.link) · [hifi-api](https://github.com/binimum/hifi-api) · [WJHE](https://music.wjhe.top) · [GDStudio](https://music.gdstudio.xyz) · [MusicDL](https://musicdl.me)
|
[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)
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -33,41 +33,12 @@ type CurrentIPInfo struct {
|
|||||||
Source string `json:"source,omitempty"`
|
Source string `json:"source,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIStatusTargetResult struct {
|
|
||||||
Target string `json:"target"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
Online bool `json:"online"`
|
|
||||||
Message string `json:"message,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type APIStatusReport struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Online bool `json:"online"`
|
|
||||||
RequireAll bool `json:"require_all"`
|
|
||||||
Details []APIStatusTargetResult `json:"details"`
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkOperationTimeout = 10 * time.Second
|
const checkOperationTimeout = 10 * time.Second
|
||||||
|
|
||||||
func NewApp() *App {
|
func NewApp() *App {
|
||||||
return &App{}
|
return &App{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) LogStatusConsole(level string, message string) {
|
|
||||||
normalizedLevel := strings.ToLower(strings.TrimSpace(level))
|
|
||||||
if normalizedLevel == "" {
|
|
||||||
normalizedLevel = "info"
|
|
||||||
}
|
|
||||||
|
|
||||||
line := fmt.Sprintf("[%s] [%s] %s\n", time.Now().Format("15:04:05"), normalizedLevel, strings.TrimSpace(message))
|
|
||||||
switch normalizedLevel {
|
|
||||||
case "error":
|
|
||||||
_, _ = fmt.Fprint(os.Stderr, line)
|
|
||||||
default:
|
|
||||||
fmt.Print(line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type timedResult[T any] struct {
|
type timedResult[T any] struct {
|
||||||
value T
|
value T
|
||||||
err error
|
err error
|
||||||
@@ -305,12 +276,11 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
if err := backend.InitProviderPriorityDB(); err != nil {
|
if err := backend.InitProviderPriorityDB(); err != nil {
|
||||||
fmt.Printf("Failed to init provider priority DB: %v\n", err)
|
fmt.Printf("Failed to init provider priority DB: %v\n", err)
|
||||||
}
|
}
|
||||||
if err := backend.CleanupLegacyTidalPublicAPIState(); err != nil {
|
go func() {
|
||||||
fmt.Printf("Failed to clean legacy Tidal API cache: %v\n", err)
|
if err := backend.PrimeTidalAPIList(); err != nil {
|
||||||
}
|
fmt.Printf("Failed to prime Tidal API list: %v\n", err)
|
||||||
if err := backend.SanitizePersistedConfigSettings(); err != nil {
|
}
|
||||||
fmt.Printf("Failed to sanitize persisted config settings: %v\n", err)
|
}()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) shutdown(ctx context.Context) {
|
func (a *App) shutdown(ctx context.Context) {
|
||||||
@@ -337,6 +307,7 @@ type DownloadRequest struct {
|
|||||||
ReleaseDate string `json:"release_date,omitempty"`
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
CoverURL string `json:"cover_url,omitempty"`
|
CoverURL string `json:"cover_url,omitempty"`
|
||||||
TidalAPIURL string `json:"tidal_api_url,omitempty"`
|
TidalAPIURL string `json:"tidal_api_url,omitempty"`
|
||||||
|
TidalVariant string `json:"tidal_variant,omitempty"`
|
||||||
OutputDir string `json:"output_dir,omitempty"`
|
OutputDir string `json:"output_dir,omitempty"`
|
||||||
AudioFormat string `json:"audio_format,omitempty"`
|
AudioFormat string `json:"audio_format,omitempty"`
|
||||||
FilenameFormat string `json:"filename_format,omitempty"`
|
FilenameFormat string `json:"filename_format,omitempty"`
|
||||||
@@ -537,8 +508,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
if req.FilenameFormat == "" {
|
if req.FilenameFormat == "" {
|
||||||
req.FilenameFormat = "title-artist"
|
req.FilenameFormat = "title-artist"
|
||||||
}
|
}
|
||||||
shouldResolveISRC := strings.Contains(req.FilenameFormat, "{isrc}") || backend.GetExistingFileCheckModeSetting() == "isrc"
|
if req.ISRC == "" && strings.Contains(req.FilenameFormat, "{isrc}") && req.SpotifyID != "" {
|
||||||
if req.ISRC == "" && shouldResolveISRC && req.SpotifyID != "" {
|
|
||||||
req.ISRC = backend.ResolveTrackISRC(req.SpotifyID)
|
req.ISRC = backend.ResolveTrackISRC(req.SpotifyID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,15 +662,24 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "tidal":
|
case "tidal":
|
||||||
if !strings.HasPrefix(strings.TrimRight(strings.TrimSpace(req.TidalAPIURL), "/"), "https://") {
|
tidalVariant := strings.ToLower(strings.TrimSpace(req.TidalVariant))
|
||||||
err = fmt.Errorf("a configured HTTPS Tidal instance is required")
|
if tidalVariant == "alt" {
|
||||||
break
|
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)
|
||||||
downloader := backend.NewTidalDownloader(req.TidalAPIURL)
|
} else if req.TidalAPIURL == "" || req.TidalAPIURL == "auto" {
|
||||||
if req.ServiceURL != "" {
|
downloader := backend.NewTidalDownloader("")
|
||||||
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)
|
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)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case "qobuz":
|
case "qobuz":
|
||||||
@@ -816,6 +795,9 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
historySource := req.Service
|
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) {
|
go func(fPath, track, artist, album, sID, cover, format, source string) {
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
@@ -844,21 +826,21 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
DurationStr: durationStr,
|
DurationStr: durationStr,
|
||||||
CoverURL: cover,
|
CoverURL: cover,
|
||||||
Quality: quality,
|
Quality: quality,
|
||||||
|
Format: strings.ToUpper(format),
|
||||||
Path: fPath,
|
Path: fPath,
|
||||||
Source: source,
|
Source: source,
|
||||||
}
|
}
|
||||||
|
|
||||||
item.Format = strings.ToUpper(strings.TrimSpace(format))
|
if item.Format == "" || item.Format == "LOSSLESS" {
|
||||||
|
ext := filepath.Ext(fPath)
|
||||||
if ext := filepath.Ext(fPath); len(ext) > 1 {
|
if len(ext) > 1 {
|
||||||
item.Format = strings.ToUpper(ext[1:])
|
item.Format = strings.ToUpper(ext[1:])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch item.Format {
|
switch item.Format {
|
||||||
case "6", "7", "27", "LOSSLESS", "HI_RES", "HI_RES_LOSSLESS":
|
case "6", "7", "27":
|
||||||
item.Format = "FLAC"
|
item.Format = "FLAC"
|
||||||
case "ALAC", "APPLE", "ATMOS", "M4A-AAC", "M4A-ALAC":
|
|
||||||
item.Format = "M4A"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
backend.AddHistoryItem(item, "SpotiFLAC")
|
backend.AddHistoryItem(item, "SpotiFLAC")
|
||||||
@@ -1011,7 +993,15 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
|
|||||||
isOnline, err := runWithTimeout(checkOperationTimeout, func() (bool, error) {
|
isOnline, err := runWithTimeout(checkOperationTimeout, func() (bool, error) {
|
||||||
switch apiType {
|
switch apiType {
|
||||||
case "tidal":
|
case "tidal":
|
||||||
return checkGroupedAPIStatus("tidal", buildTidalStatusCheckURLs(apiURL)), nil
|
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
|
||||||
case "qobuz", "qbz":
|
case "qobuz", "qbz":
|
||||||
return checkGroupedAPIStatus("qobuz", buildQobuzStatusCheckURLs(apiURL)), nil
|
return checkGroupedAPIStatus("qobuz", buildQobuzStatusCheckURLs(apiURL)), nil
|
||||||
case "amazon":
|
case "amazon":
|
||||||
@@ -1039,137 +1029,48 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
|
|||||||
return isOnline
|
return isOnline
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) CheckAPIStatusReport(apiType string, apiURL string) APIStatusReport {
|
|
||||||
report, err := runWithTimeout(checkOperationTimeout, func() (APIStatusReport, error) {
|
|
||||||
switch apiType {
|
|
||||||
case "tidal":
|
|
||||||
return buildGroupedAPIStatusReport("tidal", buildTidalStatusCheckURLs(apiURL), false), nil
|
|
||||||
case "qobuz", "qbz":
|
|
||||||
return buildGroupedAPIStatusReport("qobuz", buildQobuzStatusCheckURLs(apiURL), false), nil
|
|
||||||
case "amazon":
|
|
||||||
return buildGroupedAPIStatusReport("amazon", buildAmazonStatusCheckURLs(apiURL), false), nil
|
|
||||||
case "lrclib":
|
|
||||||
return buildGroupedAPIStatusReport("lrclib", buildLRCLIBStatusCheckURLs(apiURL), false), nil
|
|
||||||
case "musicbrainz":
|
|
||||||
return buildGroupedAPIStatusReport("musicbrainz", buildMusicBrainzStatusCheckURLs(apiURL), false), nil
|
|
||||||
default:
|
|
||||||
return buildGroupedAPIStatusReport(apiType, []string{strings.TrimSpace(apiURL)}, false), nil
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return APIStatusReport{
|
|
||||||
Type: apiType,
|
|
||||||
Online: false,
|
|
||||||
RequireAll: apiType == "qobuz" || apiType == "qbz",
|
|
||||||
Details: []APIStatusTargetResult{{
|
|
||||||
Target: strings.TrimSpace(apiURL),
|
|
||||||
Label: describeAPIStatusTarget(apiType, apiURL),
|
|
||||||
Online: false,
|
|
||||||
Message: err.Error(),
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return report
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) CheckCustomTidalAPI(apiURL string) bool {
|
|
||||||
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 {
|
func buildTidalStatusCheckURLs(apiURL string) []string {
|
||||||
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||||
if apiURL == "" {
|
if apiURL != "" {
|
||||||
return nil
|
return []string{fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL)}
|
||||||
}
|
}
|
||||||
return []string{fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL)}
|
|
||||||
|
apis, err := backend.GetRotatedTidalAPIList()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: failed to load rotated Tidal API list for status check: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
urls := make([]string, 0, len(apis))
|
||||||
|
for _, baseURL := range apis {
|
||||||
|
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||||
|
if baseURL == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
urls = append(urls, fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", baseURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
return urls
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildQobuzStatusCheckURLs(apiURL string) []string {
|
func buildQobuzStatusCheckURLs(apiURL string) []string {
|
||||||
if trimmed := strings.TrimSpace(apiURL); trimmed != "" {
|
if trimmed := strings.TrimSpace(apiURL); trimmed != "" {
|
||||||
return []string{trimmed}
|
return []string{buildQobuzStatusCheckURL(trimmed)}
|
||||||
}
|
}
|
||||||
|
|
||||||
return backend.GetQobuzDownloadProviderURLs()
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildAmazonStatusCheckURLs(apiURL string) []string {
|
func buildAmazonStatusCheckURLs(apiURL string) []string {
|
||||||
@@ -1235,224 +1136,8 @@ func checkGroupedAPIStatus(apiType string, checkURLs []string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildGroupedAPIStatusReport(apiType string, checkURLs []string, requireAll bool) APIStatusReport {
|
|
||||||
filtered := make([]string, 0, len(checkURLs))
|
|
||||||
for _, rawURL := range checkURLs {
|
|
||||||
target := strings.TrimSpace(rawURL)
|
|
||||||
if target == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filtered = append(filtered, target)
|
|
||||||
}
|
|
||||||
|
|
||||||
report := APIStatusReport{
|
|
||||||
Type: apiType,
|
|
||||||
Online: !requireAll,
|
|
||||||
RequireAll: requireAll,
|
|
||||||
Details: make([]APIStatusTargetResult, len(filtered)),
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(filtered) == 0 {
|
|
||||||
report.Online = false
|
|
||||||
return report
|
|
||||||
}
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for index, target := range filtered {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(idx int, rawTarget string) {
|
|
||||||
defer wg.Done()
|
|
||||||
report.Details[idx] = checkSingleAPIStatusDetailed(apiType, rawTarget)
|
|
||||||
}(index, target)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
if requireAll {
|
|
||||||
report.Online = true
|
|
||||||
for _, detail := range report.Details {
|
|
||||||
if !detail.Online {
|
|
||||||
report.Online = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
report.Online = false
|
|
||||||
for _, detail := range report.Details {
|
|
||||||
if detail.Online {
|
|
||||||
report.Online = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return report
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkAllGroupedAPIStatus(apiType string, checkURLs []string) bool {
|
|
||||||
filtered := make([]string, 0, len(checkURLs))
|
|
||||||
for _, rawURL := range checkURLs {
|
|
||||||
url := strings.TrimSpace(rawURL)
|
|
||||||
if url == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filtered = append(filtered, url)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(filtered) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
results := make(chan bool, len(filtered))
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
|
|
||||||
for _, checkURL := range filtered {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(target string) {
|
|
||||||
defer wg.Done()
|
|
||||||
results <- checkSingleAPIStatus(apiType, target)
|
|
||||||
}(checkURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
wg.Wait()
|
|
||||||
close(results)
|
|
||||||
}()
|
|
||||||
|
|
||||||
for online := range results {
|
|
||||||
if !online {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func describeAPIStatusTarget(apiType string, checkURL string) string {
|
|
||||||
trimmedType := strings.TrimSpace(strings.ToLower(apiType))
|
|
||||||
trimmedURL := strings.TrimSpace(checkURL)
|
|
||||||
|
|
||||||
if trimmedType == "qobuz" || trimmedType == "qbz" {
|
|
||||||
switch {
|
|
||||||
case backend.IsQobuzWJHEProviderURL(trimmedURL):
|
|
||||||
return "WJHE"
|
|
||||||
case backend.IsQobuzMusicDLProviderURL(trimmedURL):
|
|
||||||
return "MusicDL"
|
|
||||||
case backend.IsQobuzGDStudioProviderURL(trimmedURL):
|
|
||||||
parsed, err := url.Parse(trimmedURL)
|
|
||||||
if err == nil {
|
|
||||||
host := strings.ToLower(strings.TrimSpace(parsed.Host))
|
|
||||||
switch {
|
|
||||||
case strings.Contains(host, "xyz"):
|
|
||||||
return "GDStudio XYZ"
|
|
||||||
case strings.Contains(host, "org"):
|
|
||||||
return "GDStudio ORG"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "GDStudio"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if trimmedURL != "" {
|
|
||||||
if parsed, err := url.Parse(trimmedURL); err == nil && strings.TrimSpace(parsed.Host) != "" {
|
|
||||||
return strings.TrimSpace(parsed.Host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if trimmedType == "" {
|
|
||||||
return "Unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.ToUpper(trimmedType)
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkSingleAPIStatusDetailed(apiType string, checkURL string) APIStatusTargetResult {
|
|
||||||
result := APIStatusTargetResult{
|
|
||||||
Target: strings.TrimSpace(checkURL),
|
|
||||||
Label: describeAPIStatusTarget(apiType, checkURL),
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 4 * time.Second}
|
|
||||||
trimmedType := strings.TrimSpace(strings.ToLower(apiType))
|
|
||||||
|
|
||||||
if trimmedType == "qobuz" || trimmedType == "qbz" {
|
|
||||||
var err error
|
|
||||||
switch {
|
|
||||||
case backend.IsQobuzWJHEProviderURL(checkURL):
|
|
||||||
err = backend.CheckQobuzWJHEStatusDetailed(client)
|
|
||||||
case backend.IsQobuzMusicDLProviderURL(checkURL):
|
|
||||||
err = backend.CheckQobuzMusicDLStatusDetailed(client)
|
|
||||||
case backend.IsQobuzGDStudioProviderURL(checkURL):
|
|
||||||
err = backend.CheckQobuzGDStudioAPIStatusDetailed(client, checkURL)
|
|
||||||
default:
|
|
||||||
err = fmt.Errorf("unknown qobuz provider url: %s", strings.TrimSpace(checkURL))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
result.Message = err.Error()
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Online = true
|
|
||||||
result.Message = "stream URL resolved"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := backend.NewRequestWithDefaultHeaders(http.MethodGet, checkURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
result.Message = fmt.Sprintf("failed to create request: %v", err)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
result.Message = fmt.Sprintf("request failed: %v", err)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
||||||
if err != nil {
|
|
||||||
result.Message = fmt.Sprintf("failed to read response: %v", err)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
switch trimmedType {
|
|
||||||
case "amazon":
|
|
||||||
if resp.StatusCode == http.StatusOK && strings.Contains(string(body), `"amazonMusic":"up"`) {
|
|
||||||
result.Online = true
|
|
||||||
result.Message = `amazonMusic="up"`
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
result.Message = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, previewResponseBody(body, 160))
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
result.Message = `amazonMusic was not reported as "up"`
|
|
||||||
return result
|
|
||||||
default:
|
|
||||||
if resp.StatusCode == http.StatusOK {
|
|
||||||
result.Online = true
|
|
||||||
result.Message = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
result.Message = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, previewResponseBody(body, 160))
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkSingleAPIStatus(apiType string, checkURL string) bool {
|
func checkSingleAPIStatus(apiType string, checkURL string) bool {
|
||||||
client := &http.Client{Timeout: 4 * time.Second}
|
client := &http.Client{Timeout: 4 * time.Second}
|
||||||
if apiType == "qobuz" || apiType == "qbz" {
|
|
||||||
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)
|
req, err := backend.NewRequestWithDefaultHeaders(http.MethodGet, checkURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
@@ -2048,68 +1733,6 @@ type CheckFileExistenceResult struct {
|
|||||||
ArtistName string `json:"artist_name,omitempty"`
|
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 {
|
func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []CheckFileExistenceRequest) []CheckFileExistenceResult {
|
||||||
if len(tracks) == 0 {
|
if len(tracks) == 0 {
|
||||||
return []CheckFileExistenceResult{}
|
return []CheckFileExistenceResult{}
|
||||||
@@ -2122,11 +1745,6 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
|
|||||||
|
|
||||||
defaultFilenameFormat := "title-artist"
|
defaultFilenameFormat := "title-artist"
|
||||||
redownloadWithSuffix := backend.GetRedownloadWithSuffixSetting()
|
redownloadWithSuffix := backend.GetRedownloadWithSuffixSetting()
|
||||||
existingFileCheckMode := backend.GetExistingFileCheckModeSetting()
|
|
||||||
scanRoot := outputDir
|
|
||||||
if rootDir != "" {
|
|
||||||
scanRoot = rootDir
|
|
||||||
}
|
|
||||||
|
|
||||||
type result struct {
|
type result struct {
|
||||||
index int
|
index int
|
||||||
@@ -2134,13 +1752,29 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
|
|||||||
}
|
}
|
||||||
|
|
||||||
resultsChan := make(chan result, len(tracks))
|
resultsChan := make(chan result, len(tracks))
|
||||||
var lookupIndex existingFileLookupIndex
|
|
||||||
var lookupIndexOnce sync.Once
|
var rootDirFiles map[string]string
|
||||||
getLookupIndex := func() existingFileLookupIndex {
|
rootDirFilesOnce := false
|
||||||
lookupIndexOnce.Do(func() {
|
getRootDirFiles := func() map[string]string {
|
||||||
lookupIndex = buildExistingFileLookupIndex(scanRoot, existingFileCheckMode)
|
if rootDirFilesOnce {
|
||||||
})
|
return rootDirFiles
|
||||||
return lookupIndex
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, track := range tracks {
|
for i, track := range tracks {
|
||||||
@@ -2162,8 +1796,7 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
|
|||||||
filenameFormat = defaultFilenameFormat
|
filenameFormat = defaultFilenameFormat
|
||||||
}
|
}
|
||||||
isrc := strings.TrimSpace(t.ISRC)
|
isrc := strings.TrimSpace(t.ISRC)
|
||||||
shouldResolveISRC := existingFileCheckMode == "isrc" || strings.Contains(filenameFormat, "{isrc}")
|
if isrc == "" && strings.Contains(filenameFormat, "{isrc}") && t.SpotifyID != "" {
|
||||||
if isrc == "" && shouldResolveISRC && t.SpotifyID != "" {
|
|
||||||
isrc = backend.ResolveTrackISRC(t.SpotifyID)
|
isrc = backend.ResolveTrackISRC(t.SpotifyID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2173,11 +1806,8 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
|
|||||||
}
|
}
|
||||||
|
|
||||||
fileExt := ".flac"
|
fileExt := ".flac"
|
||||||
switch strings.ToLower(strings.TrimSpace(t.AudioFormat)) {
|
if t.AudioFormat == "mp3" {
|
||||||
case "mp3":
|
|
||||||
fileExt = ".mp3"
|
fileExt = ".mp3"
|
||||||
case "m4a", "m4a-aac", "m4a-alac", "alac", "atmos", "apple":
|
|
||||||
fileExt = ".m4a"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
expectedFilenameBase := backend.BuildExpectedFilename(
|
expectedFilenameBase := backend.BuildExpectedFilename(
|
||||||
@@ -2206,29 +1836,14 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
|
|||||||
expectedPath := filepath.Join(targetDir, expectedFilename)
|
expectedPath := filepath.Join(targetDir, expectedFilename)
|
||||||
if redownloadWithSuffix {
|
if redownloadWithSuffix {
|
||||||
expectedPath, _ = backend.ResolveOutputPathForDownload(expectedPath, true)
|
expectedPath, _ = backend.ResolveOutputPathForDownload(expectedPath, true)
|
||||||
resultsChan <- result{index: idx, result: res}
|
res.FilePath = filepath.Base(expectedPath)
|
||||||
return
|
} else {
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
|
||||||
res.Exists = true
|
res.Exists = true
|
||||||
res.FilePath = expectedPath
|
res.FilePath = expectedPath
|
||||||
} else if path, ok := getLookupIndex().byFilename[filepath.Base(expectedPath)]; ok {
|
} else {
|
||||||
res.Exists = true
|
|
||||||
res.FilePath = path
|
res.FilePath = expectedFilename
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2237,10 +1852,39 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
|
|||||||
}
|
}
|
||||||
|
|
||||||
results := make([]CheckFileExistenceResult, len(tracks))
|
results := make([]CheckFileExistenceResult, len(tracks))
|
||||||
|
missingIndices := []int{}
|
||||||
|
|
||||||
for i := 0; i < len(tracks); i++ {
|
for i := 0; i < len(tracks); i++ {
|
||||||
r := <-resultsChan
|
r := <-resultsChan
|
||||||
results[r.index] = r.result
|
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
|
return results
|
||||||
@@ -2266,20 +1910,11 @@ func (a *App) GetConfigPath() (string, error) {
|
|||||||
return filepath.Join(dir, "config.json"), nil
|
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 {
|
func (a *App) SaveSettings(settings map[string]interface{}) error {
|
||||||
configPath, err := a.GetConfigPath()
|
configPath, err := a.GetConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
settings = backend.SanitizeSettingsMap(settings)
|
|
||||||
|
|
||||||
dir := filepath.Dir(configPath)
|
dir := filepath.Dir(configPath)
|
||||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||||
@@ -2296,27 +1931,6 @@ func (a *App) SaveSettings(settings map[string]interface{}) error {
|
|||||||
return os.WriteFile(configPath, data, 0644)
|
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) {
|
func (a *App) LoadSettings() (map[string]interface{}, error) {
|
||||||
configPath, err := a.GetConfigPath()
|
configPath, err := a.GetConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2337,33 +1951,7 @@ func (a *App) LoadSettings() (map[string]interface{}, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return backend.SanitizeSettingsMap(settings), nil
|
return 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) {
|
func (a *App) CheckFFmpegInstalled() (bool, error) {
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/aes"
|
|
||||||
"crypto/cipher"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -13,7 +10,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,76 +23,6 @@ type AmazonStreamResponse struct {
|
|||||||
DecryptionKey string `json:"decryptionKey"`
|
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 {
|
func NewAmazonDownloader() *AmazonDownloader {
|
||||||
return &AmazonDownloader{
|
return &AmazonDownloader{
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
@@ -136,12 +62,6 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
|
|||||||
return "", err
|
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)
|
fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin)
|
||||||
resp, err := a.client.Do(req)
|
resp, err := a.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+1
-156
@@ -2,138 +2,11 @@ package backend
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const legacyTidalAPICacheFile = "tidal-api-urls.json"
|
|
||||||
|
|
||||||
func normalizeCustomTidalAPIValue(value interface{}) string {
|
|
||||||
customAPI, _ := value.(string)
|
|
||||||
customAPI = strings.TrimRight(strings.TrimSpace(customAPI), "/")
|
|
||||||
if strings.HasPrefix(customAPI, "https://") {
|
|
||||||
return customAPI
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func sanitizeDownloaderValue(value interface{}, allowTidal bool) string {
|
|
||||||
downloader, _ := value.(string)
|
|
||||||
switch strings.TrimSpace(strings.ToLower(downloader)) {
|
|
||||||
case "tidal":
|
|
||||||
if allowTidal {
|
|
||||||
return "tidal"
|
|
||||||
}
|
|
||||||
return "auto"
|
|
||||||
case "qobuz":
|
|
||||||
return "qobuz"
|
|
||||||
case "amazon":
|
|
||||||
return "amazon"
|
|
||||||
default:
|
|
||||||
return "auto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sanitizeAutoOrderValue(value interface{}, allowTidal bool) string {
|
|
||||||
autoOrder, _ := value.(string)
|
|
||||||
allowed := map[string]struct{}{
|
|
||||||
"qobuz": {},
|
|
||||||
"amazon": {},
|
|
||||||
}
|
|
||||||
fallback := "qobuz-amazon"
|
|
||||||
if allowTidal {
|
|
||||||
allowed["tidal"] = struct{}{}
|
|
||||||
fallback = "tidal-qobuz-amazon"
|
|
||||||
}
|
|
||||||
|
|
||||||
seen := make(map[string]struct{})
|
|
||||||
parts := make([]string, 0, 3)
|
|
||||||
for _, rawPart := range strings.Split(strings.TrimSpace(strings.ToLower(autoOrder)), "-") {
|
|
||||||
part := strings.TrimSpace(rawPart)
|
|
||||||
if part == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := allowed[part]; !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := seen[part]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[part] = struct{}{}
|
|
||||||
parts = append(parts, part)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(parts) < 2 {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(parts, "-")
|
|
||||||
}
|
|
||||||
|
|
||||||
func SanitizeSettingsMap(settings map[string]interface{}) map[string]interface{} {
|
|
||||||
if settings == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
sanitized := make(map[string]interface{}, len(settings))
|
|
||||||
for key, value := range settings {
|
|
||||||
sanitized[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
customAPI := normalizeCustomTidalAPIValue(sanitized["customTidalApi"])
|
|
||||||
sanitized["customTidalApi"] = customAPI
|
|
||||||
allowTidal := customAPI != ""
|
|
||||||
sanitized["downloader"] = sanitizeDownloaderValue(sanitized["downloader"], allowTidal)
|
|
||||||
sanitized["autoOrder"] = sanitizeAutoOrderValue(sanitized["autoOrder"], allowTidal)
|
|
||||||
|
|
||||||
return sanitized
|
|
||||||
}
|
|
||||||
|
|
||||||
func CleanupLegacyTidalPublicAPIState() error {
|
|
||||||
appDir, err := EnsureAppDir()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cachePath := filepath.Join(appDir, legacyTidalAPICacheFile)
|
|
||||||
if err := os.Remove(cachePath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func SanitizePersistedConfigSettings() error {
|
|
||||||
configPath, err := GetConfigPath()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var settings map[string]interface{}
|
|
||||||
if err := json.Unmarshal(data, &settings); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
sanitized := SanitizeSettingsMap(settings)
|
|
||||||
payload, err := json.MarshalIndent(sanitized, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return os.WriteFile(configPath, payload, 0o644)
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetDefaultMusicPath() string {
|
func GetDefaultMusicPath() string {
|
||||||
|
|
||||||
homeDir, err := os.UserHomeDir()
|
homeDir, err := os.UserHomeDir()
|
||||||
@@ -174,7 +47,7 @@ func LoadConfigSettings() (map[string]interface{}, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return SanitizeSettingsMap(settings), nil
|
return settings, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetRedownloadWithSuffixSetting() bool {
|
func GetRedownloadWithSuffixSetting() bool {
|
||||||
@@ -187,34 +60,6 @@ func GetRedownloadWithSuffixSetting() bool {
|
|||||||
return enabled
|
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 {
|
func GetLinkResolverSetting() string {
|
||||||
settings, err := LoadConfigSettings()
|
settings, err := LoadConfigSettings()
|
||||||
if err != nil || settings == nil {
|
if err != nil || settings == nil {
|
||||||
|
|||||||
+49
-186
@@ -19,11 +19,6 @@ import (
|
|||||||
"golang.org/x/text/unicode/norm"
|
"golang.org/x/text/unicode/norm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type executableCandidate struct {
|
|
||||||
path string
|
|
||||||
source string
|
|
||||||
}
|
|
||||||
|
|
||||||
func ValidateExecutable(path string) error {
|
func ValidateExecutable(path string) error {
|
||||||
cleanedPath := filepath.Clean(path)
|
cleanedPath := filepath.Clean(path)
|
||||||
if cleanedPath == "" {
|
if cleanedPath == "" {
|
||||||
@@ -88,50 +83,6 @@ func GetFFmpegDir() (string, error) {
|
|||||||
return EnsureAppDir()
|
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 {
|
func resolveSystemExecutable(executableName string) string {
|
||||||
if runtime.GOOS == "darwin" {
|
if runtime.GOOS == "darwin" {
|
||||||
candidates := []string{
|
candidates := []string{
|
||||||
@@ -163,163 +114,83 @@ func resolveSystemExecutable(executableName string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func runExecutableVersionCheck(path string) error {
|
func GetFFmpegPath() (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()
|
ffmpegDir, err := GetFFmpegDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
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"
|
ffmpegName := "ffmpeg"
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
ffmpegName = "ffmpeg.exe"
|
ffmpegName = "ffmpeg.exe"
|
||||||
}
|
}
|
||||||
|
|
||||||
path, localPath, err := resolveExecutablePath(ffmpegName)
|
if path := resolveSystemExecutable(ffmpegName); path != "" {
|
||||||
if err != nil {
|
return path, nil
|
||||||
if localPath != "" {
|
|
||||||
return localPath, err
|
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
func GetFFprobePath() (string, error) {
|
||||||
|
ffmpegDir, err := GetFFmpegDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
ffprobeName := "ffprobe"
|
ffprobeName := "ffprobe"
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
ffprobeName = "ffprobe.exe"
|
ffprobeName = "ffprobe.exe"
|
||||||
}
|
}
|
||||||
|
|
||||||
path, localPath, err := resolveExecutablePath(ffprobeName)
|
if path := resolveSystemExecutable(ffprobeName); path != "" {
|
||||||
if err != nil {
|
return path, nil
|
||||||
if localPath != "" {
|
|
||||||
return localPath, err
|
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return path, nil
|
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
func IsFFprobeInstalled() (bool, error) {
|
func IsFFprobeInstalled() (bool, error) {
|
||||||
_, err := GetFFprobePath()
|
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()
|
||||||
return err == nil, nil
|
return err == nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func IsFFmpegInstalled() (bool, error) {
|
func IsFFmpegInstalled() (bool, error) {
|
||||||
if _, err := GetFFmpegPath(); err != nil {
|
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 {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,10 +507,6 @@ func extractZip(zipPath, destDir string) error {
|
|||||||
return fmt.Errorf("failed to extract file: %w", err)
|
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)
|
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -717,10 +584,6 @@ func extractTarXz(tarXzPath, destDir string) error {
|
|||||||
return fmt.Errorf("failed to extract file: %w", err)
|
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)
|
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,86 +1,15 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
const amazonMusicAPIBaseURL = "https://amazon.spotbye.qzz.io"
|
const amazonMusicAPIBaseURL = "https://amazon.spotbye.qzz.io"
|
||||||
|
|
||||||
const (
|
var defaultQobuzStreamAPIBaseURLs = []string{
|
||||||
qobuzWJHEBaseURL = "https://music.wjhe.top"
|
"https://dab.yeet.su/api/stream?trackId=",
|
||||||
qobuzWJHESearchAPIURL = qobuzWJHEBaseURL + "/api/music/qobuz/search"
|
"https://dabmusic.xyz/api/stream?trackId=",
|
||||||
qobuzWJHEStreamAPIURL = qobuzWJHEBaseURL + "/api/music/qobuz/url"
|
"https://qobuz.spotbye.qzz.io/api/track/",
|
||||||
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 GetQobuzDownloadProviderURLs() []string {
|
func GetQobuzStreamAPIBaseURLs() []string {
|
||||||
return append([]string(nil), defaultQobuzDownloadProviderURLs...)
|
return append([]string(nil), defaultQobuzStreamAPIBaseURLs...)
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
func GetAmazonMusicAPIBaseURL() string {
|
||||||
|
|||||||
+103
-662
@@ -1,12 +1,6 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"crypto/aes"
|
|
||||||
"crypto/cipher"
|
|
||||||
"crypto/md5"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -15,14 +9,23 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type QobuzDownloader struct {
|
type QobuzDownloader struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
appID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type QobuzSearchResponse struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Tracks struct {
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Items []QobuzTrack `json:"items"`
|
||||||
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type QobuzTrack struct {
|
type QobuzTrack struct {
|
||||||
@@ -61,63 +64,8 @@ type QobuzTrack struct {
|
|||||||
} `json:"album"`
|
} `json:"album"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type qobuzMusicDLRequest struct {
|
type QobuzStreamResponse struct {
|
||||||
URL string `json:"url"`
|
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 {
|
func NewQobuzDownloader() *QobuzDownloader {
|
||||||
@@ -125,625 +73,119 @@ func NewQobuzDownloader() *QobuzDownloader {
|
|||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Timeout: 60 * time.Second,
|
Timeout: 60 * time.Second,
|
||||||
},
|
},
|
||||||
|
appID: qobuzDefaultAPIAppID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func previewQobuzResponseBody(body []byte, maxLen int) string {
|
func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
|
||||||
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_") {
|
if strings.HasPrefix(isrc, "qobuz_") {
|
||||||
trackID := strings.TrimSpace(strings.TrimPrefix(isrc, "qobuz_"))
|
trackID := strings.TrimPrefix(isrc, "qobuz_")
|
||||||
resp, err := doQobuzSignedRequest(http.MethodGet, "track/get", url.Values{"track_id": {trackID}}, q.client)
|
resp, err := doQobuzSignedRequest(http.MethodGet, "track/get", url.Values{"track_id": {trackID}}, q.client)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch track from Qobuz public API: %w", err)
|
return nil, fmt.Errorf("failed to fetch track: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||||
return nil, fmt.Errorf("Qobuz public API track/get returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var trackResp QobuzTrack
|
var trackResp QobuzTrack
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&trackResp); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&trackResp); err != nil {
|
||||||
return nil, fmt.Errorf("failed to decode Qobuz public track/get response: %w", err)
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &trackResp, nil
|
return &trackResp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
queries := []string{strings.TrimSpace(isrc)}
|
resp, err := doQobuzSignedRequest(http.MethodGet, "track/search", url.Values{
|
||||||
if fallbackQuery := strings.TrimSpace(strings.Join([]string{spotifyTrackName, spotifyArtistName}, " ")); fallbackQuery != "" {
|
"query": {isrc},
|
||||||
queries = append(queries, fallbackQuery)
|
"limit": {"1"},
|
||||||
}
|
}, q.client)
|
||||||
|
|
||||||
var lastErr error
|
|
||||||
for _, query := range queries {
|
|
||||||
if strings.TrimSpace(query) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var searchResp qobuzPublicSearchResponse
|
|
||||||
if err := doQobuzSignedJSONRequest("track/search", url.Values{
|
|
||||||
"query": {strings.TrimSpace(query)},
|
|
||||||
"limit": {"10"},
|
|
||||||
}, &searchResp); err != nil {
|
|
||||||
lastErr = fmt.Errorf("failed to search Qobuz public API: %w", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if searchResp.Tracks.Total == 0 || len(searchResp.Tracks.Items) == 0 {
|
|
||||||
lastErr = fmt.Errorf("track not found for query: %s", query)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
bestIndex := 0
|
|
||||||
bestScore := -1
|
|
||||||
for idx, candidate := range searchResp.Tracks.Items {
|
|
||||||
score := scoreQobuzSearchCandidate(candidate, spotifyTrackName, spotifyArtistName, spotifyAlbumName)
|
|
||||||
if idx == 0 || score > bestScore {
|
|
||||||
bestIndex = idx
|
|
||||||
bestScore = score
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
selected := searchResp.Tracks.Items[bestIndex]
|
|
||||||
return &selected, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if lastErr == nil {
|
|
||||||
lastErr = fmt.Errorf("track not found for ISRC: %s", isrc)
|
|
||||||
}
|
|
||||||
return nil, lastErr
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *QobuzDownloader) DownloadFromWJHE(trackID int64, quality string) (string, error) {
|
|
||||||
apiURL := buildQobuzWJHEDownloadURL(trackID, quality)
|
|
||||||
client := newQobuzNoRedirectClient(q.client)
|
|
||||||
|
|
||||||
req, err := NewRequestWithDefaultHeaders(http.MethodHead, apiURL, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create WJHE request: %w", err)
|
return nil, fmt.Errorf("failed to search track: %w", err)
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to reach WJHE: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotImplemented {
|
|
||||||
resp.Body.Close()
|
|
||||||
req, err = NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create WJHE fallback request: %w", err)
|
|
||||||
}
|
|
||||||
resp, err = client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to reach WJHE with GET fallback: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if location := strings.TrimSpace(resp.Header.Get("Location")); qobuzURLLooksStreamable(location) {
|
|
||||||
return location, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 128*1024))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read WJHE response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if streamURL := extractQobuzStreamingURL(body); streamURL != "" {
|
|
||||||
return streamURL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.Request != nil && resp.Request.URL != nil {
|
|
||||||
if streamURL := strings.TrimSpace(resp.Request.URL.String()); streamURL != "" && streamURL != apiURL && qobuzURLLooksStreamable(streamURL) {
|
|
||||||
return streamURL, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
|
|
||||||
return "", fmt.Errorf("WJHE returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("WJHE response did not include a stream URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
func qobuzGDStudioPaddedVersion() string {
|
|
||||||
parts := strings.Split(GetQobuzGDStudioVersion(), ".")
|
|
||||||
for idx, part := range parts {
|
|
||||||
part = strings.TrimSpace(part)
|
|
||||||
if len(part) == 1 {
|
|
||||||
part = "0" + part
|
|
||||||
}
|
|
||||||
parts[idx] = part
|
|
||||||
}
|
|
||||||
return strings.Join(parts, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func 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 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 "", fmt.Errorf("failed to reach GDStudio: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
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 {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", fmt.Errorf("GDStudio returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
|
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
streamURL := extractQobuzStreamingURL(body)
|
var searchResp QobuzSearchResponse
|
||||||
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)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read MusicDL response: %w", err)
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if len(body) == 0 {
|
||||||
return "", fmt.Errorf("MusicDL returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
|
return nil, fmt.Errorf("API returned empty response")
|
||||||
}
|
}
|
||||||
|
|
||||||
var downloadResp qobuzMusicDLResponse
|
if err := json.Unmarshal(body, &searchResp); err != nil {
|
||||||
if err := json.Unmarshal(body, &downloadResp); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to decode MusicDL response: %w (%s)", err, previewQobuzResponseBody(body, 256))
|
|
||||||
}
|
|
||||||
|
|
||||||
if !downloadResp.Success {
|
bodyStr := string(body)
|
||||||
message := strings.TrimSpace(downloadResp.Error)
|
if len(bodyStr) > 200 {
|
||||||
if message == "" {
|
bodyStr = bodyStr[:200] + "..."
|
||||||
message = strings.TrimSpace(downloadResp.Message)
|
|
||||||
}
|
}
|
||||||
if message == "" {
|
return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
|
||||||
message = "MusicDL reported failure"
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("%s", message)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadURL := strings.TrimSpace(downloadResp.DownloadURL)
|
if len(searchResp.Tracks.Items) == 0 {
|
||||||
if downloadURL == "" {
|
return nil, fmt.Errorf("track not found for ISRC: %s", isrc)
|
||||||
return "", fmt.Errorf("MusicDL response did not include a download_url")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return downloadURL, nil
|
return &searchResp.Tracks.Items[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func CheckQobuzMusicDLStatusDetailed(client *http.Client) error {
|
func buildQobuzAPIURL(apiBase string, trackID int64, quality string) string {
|
||||||
if client == nil {
|
if strings.Contains(apiBase, "qobuz.spotbye.qzz.io") {
|
||||||
client = &http.Client{Timeout: 4 * time.Second}
|
return fmt.Sprintf("%s%d?quality=%s", apiBase, trackID, quality)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, quality string) (string, error) {
|
||||||
|
apiURL := buildQobuzAPIURL(apiBase, trackID, quality)
|
||||||
|
req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
downloader := &QobuzDownloader{client: client}
|
resp, err := q.client.Do(req)
|
||||||
_, err := downloader.DownloadFromMusicDL(qobuzProbeTrackID, "27")
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
func CheckQobuzMusicDLStatus(client *http.Client) bool {
|
if resp.StatusCode != 200 {
|
||||||
return CheckQobuzMusicDLStatusDetailed(client) == nil
|
return "", fmt.Errorf("status %d", resp.StatusCode)
|
||||||
}
|
|
||||||
|
|
||||||
func CheckQobuzWJHEStatusDetailed(client *http.Client) error {
|
|
||||||
if client == nil {
|
|
||||||
client = &http.Client{Timeout: 4 * time.Second}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
downloader := &QobuzDownloader{client: client}
|
body, err := io.ReadAll(resp.Body)
|
||||||
_, err := downloader.DownloadFromWJHE(qobuzProbeTrackID, "27")
|
if err != nil {
|
||||||
return err
|
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}
|
if len(body) == 0 {
|
||||||
_, err := downloader.DownloadFromGDStudio(qobuzProbeTrackID, "27", apiURL)
|
return "", fmt.Errorf("empty body")
|
||||||
return err
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func CheckQobuzGDStudioAPIStatus(client *http.Client, apiURL string) bool {
|
var streamResp QobuzStreamResponse
|
||||||
return CheckQobuzGDStudioAPIStatusDetailed(client, apiURL) == nil
|
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
|
||||||
|
return streamResp.URL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var nestedResp struct {
|
||||||
|
Data struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &nestedResp); err == nil && nestedResp.Data.URL != "" {
|
||||||
|
return nestedResp.Data.URL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("invalid response")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFallback bool) (string, error) {
|
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFallback bool) (string, error) {
|
||||||
@@ -754,36 +196,41 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
|
|||||||
|
|
||||||
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
|
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
|
||||||
|
|
||||||
|
standardAPIs := prioritizeProviders("qobuz", GetQobuzStreamAPIBaseURLs())
|
||||||
|
|
||||||
downloadFunc := func(qual string) (string, error) {
|
downloadFunc := func(qual string) (string, error) {
|
||||||
attemptMap := make(map[string]qobuzProviderAttempt)
|
type Provider struct {
|
||||||
attemptIDs := make([]string, 0, len(GetQobuzDownloadProviderURLs()))
|
Name string
|
||||||
for _, provider := range q.getQobuzDownloadProviders() {
|
API string
|
||||||
for _, attempt := range provider.Attempts(trackID, qual) {
|
Func func() (string, error)
|
||||||
attemptMap[attempt.ID] = attempt
|
}
|
||||||
attemptIDs = append(attemptIDs, attempt.ID)
|
|
||||||
}
|
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)
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
orderedProviderIDs := prioritizeProviders("qobuz", attemptIDs)
|
|
||||||
orderedProviderIDs = moveQobuzAttemptIDsLast(orderedProviderIDs, GetQobuzMusicDLDownloadAPIURL())
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for _, providerID := range orderedProviderIDs {
|
for _, p := range providers {
|
||||||
attempt, ok := attemptMap[providerID]
|
fmt.Printf("Trying Provider: %s (Quality: %s)...\n", p.Name, qual)
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Trying Provider: %s (Quality: %s)...\n", attempt.Name, qual)
|
url, err := p.Func()
|
||||||
|
|
||||||
url, err := attempt.Download()
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
fmt.Printf("✓ Success\n")
|
fmt.Printf("✓ Success\n")
|
||||||
recordProviderSuccess("qobuz", attempt.ID)
|
recordProviderSuccess("qobuz", p.API)
|
||||||
return url, nil
|
return url, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Provider failed: %v\n", err)
|
fmt.Printf("Provider failed: %v\n", err)
|
||||||
recordProviderFailure("qobuz", attempt.ID)
|
recordProviderFailure("qobuz", p.API)
|
||||||
lastErr = err
|
lastErr = err
|
||||||
}
|
}
|
||||||
return "", lastErr
|
return "", lastErr
|
||||||
@@ -996,7 +443,7 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
track, err := q.searchByISRC(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName)
|
track, err := q.searchByISRC(isrc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -1010,13 +457,7 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena
|
|||||||
|
|
||||||
qualityInfo := "Standard"
|
qualityInfo := "Standard"
|
||||||
if track.Hires {
|
if track.Hires {
|
||||||
if track.MaximumBitDepth > 0 && track.MaximumSamplingRate > 0 {
|
qualityInfo = fmt.Sprintf("Hi-Res (%d-bit / %.1f kHz)", track.MaximumBitDepth, track.MaximumSamplingRate)
|
||||||
qualityInfo = fmt.Sprintf("Hi-Res (%d-bit / %.1f kHz)", track.MaximumBitDepth, track.MaximumSamplingRate)
|
|
||||||
} else if track.MaximumBitDepth > 0 {
|
|
||||||
qualityInfo = fmt.Sprintf("Hi-Res available (%d-bit)", track.MaximumBitDepth)
|
|
||||||
} else {
|
|
||||||
qualityInfo = "Hi-Res available"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fmt.Printf("Quality: %s\n", qualityInfo)
|
fmt.Printf("Quality: %s\n", qualityInfo)
|
||||||
|
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
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...)
|
|
||||||
}
|
|
||||||
+35
-175
@@ -9,7 +9,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -48,154 +47,15 @@ type TidalBTSManifest struct {
|
|||||||
URLs []string `json:"urls"`
|
URLs []string `json:"urls"`
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
func NewTidalDownloader(apiURL string) *TidalDownloader {
|
||||||
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||||
|
if apiURL == "" {
|
||||||
|
apis, err := GetRotatedTidalAPIList()
|
||||||
|
if err == nil && len(apis) > 0 {
|
||||||
|
apiURL = apis[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &TidalDownloader{
|
return &TidalDownloader{
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Timeout: 5 * time.Second,
|
Timeout: 5 * time.Second,
|
||||||
@@ -207,7 +67,7 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
||||||
apis, err := getConfiguredTidalAPIAttemptList()
|
apis, err := GetRotatedTidalAPIList()
|
||||||
if err == nil && len(apis) > 0 {
|
if err == nil && len(apis) > 0 {
|
||||||
return apis, nil
|
return apis, nil
|
||||||
}
|
}
|
||||||
@@ -252,9 +112,6 @@ func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
|
|||||||
|
|
||||||
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
||||||
fmt.Println("Fetching URL...")
|
fmt.Println("Fetching URL...")
|
||||||
if strings.TrimSpace(t.apiURL) == "" {
|
|
||||||
return "", fmt.Errorf("no configured custom tidal api instance")
|
|
||||||
}
|
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality)
|
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality)
|
||||||
fmt.Printf("Tidal API URL: %s\n", url)
|
fmt.Printf("Tidal API URL: %s\n", url)
|
||||||
@@ -316,10 +173,10 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||||||
return "", fmt.Errorf("download URL not found in response")
|
return "", fmt.Errorf("download URL not found in response")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) DownloadFile(url, filepath string, quality string) error {
|
func (t *TidalDownloader) DownloadFile(url, filepath string) error {
|
||||||
|
|
||||||
if strings.HasPrefix(url, "MANIFEST:") {
|
if strings.HasPrefix(url, "MANIFEST:") {
|
||||||
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath, quality)
|
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
|
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
|
||||||
@@ -356,18 +213,12 @@ func (t *TidalDownloader) DownloadFile(url, filepath string, quality string) err
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string, quality string) error {
|
func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) error {
|
||||||
directURL, initURL, mediaURLs, mimeType, err := parseManifest(manifestB64)
|
directURL, initURL, mediaURLs, mimeType, err := parseManifest(manifestB64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse manifest: %w", err)
|
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{
|
client := &http.Client{
|
||||||
Timeout: 120 * time.Second,
|
Timeout: 120 * time.Second,
|
||||||
}
|
}
|
||||||
@@ -582,10 +433,15 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Downloading to: %s\n", outputFilename)
|
fmt.Printf("Downloading to: %s\n", outputFilename)
|
||||||
if err := t.DownloadFile(downloadURL, outputFilename, quality); err != nil {
|
if err := t.DownloadFile(downloadURL, outputFilename); err != nil {
|
||||||
cleanupTidalDownloadArtifacts(outputFilename)
|
cleanupTidalDownloadArtifacts(outputFilename)
|
||||||
return outputFilename, err
|
return outputFilename, err
|
||||||
}
|
}
|
||||||
|
if t.apiURL != "" {
|
||||||
|
if err := RememberTidalAPIUsage(t.apiURL); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to persist last used Tidal API: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre)
|
finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre)
|
||||||
|
|
||||||
@@ -637,10 +493,7 @@ func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameF
|
|||||||
return "", fmt.Errorf("songlink/songstats couldn't find Tidal URL: %w", err)
|
return "", fmt.Errorf("songlink/songstats couldn't find Tidal URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.apiURL == "" {
|
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
|
||||||
return "", 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 {
|
type SegmentTemplate struct {
|
||||||
@@ -697,12 +550,10 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
|
|
||||||
var mpd MPD
|
var mpd MPD
|
||||||
var segTemplate *SegmentTemplate
|
var segTemplate *SegmentTemplate
|
||||||
var dashMimeType string
|
|
||||||
|
|
||||||
if err := xml.Unmarshal(manifestBytes, &mpd); err == nil {
|
if err := xml.Unmarshal(manifestBytes, &mpd); err == nil {
|
||||||
var selectedBandwidth int
|
var selectedBandwidth int
|
||||||
var selectedCodecs string
|
var selectedCodecs string
|
||||||
var selectedMimeType string
|
|
||||||
|
|
||||||
for _, as := range mpd.Period.AdaptationSets {
|
for _, as := range mpd.Period.AdaptationSets {
|
||||||
|
|
||||||
@@ -711,7 +562,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
if segTemplate == nil {
|
if segTemplate == nil {
|
||||||
segTemplate = as.SegmentTemplate
|
segTemplate = as.SegmentTemplate
|
||||||
selectedCodecs = as.Codecs
|
selectedCodecs = as.Codecs
|
||||||
selectedMimeType = as.MimeType
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -726,8 +576,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
} else {
|
} else {
|
||||||
selectedCodecs = as.Codecs
|
selectedCodecs = as.Codecs
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedMimeType = as.MimeType
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -735,7 +583,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
|
|
||||||
if selectedBandwidth > 0 {
|
if selectedBandwidth > 0 {
|
||||||
fmt.Printf("Selected stream: Codec=%s, Bandwidth=%d bps\n", selectedCodecs, selectedBandwidth)
|
fmt.Printf("Selected stream: Codec=%s, Bandwidth=%d bps\n", selectedCodecs, selectedBandwidth)
|
||||||
dashMimeType = fmt.Sprintf("%s; codecs=\"%s\"", selectedMimeType, selectedCodecs)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -761,7 +608,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
||||||
mediaURLs = append(mediaURLs, mediaURL)
|
mediaURLs = append(mediaURLs, mediaURL)
|
||||||
}
|
}
|
||||||
return "", initURL, mediaURLs, dashMimeType, nil
|
return "", initURL, mediaURLs, "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Using regex fallback for DASH manifest...")
|
fmt.Println("Using regex fallback for DASH manifest...")
|
||||||
@@ -808,7 +655,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
mediaURLs = append(mediaURLs, mediaURL)
|
mediaURLs = append(mediaURLs, mediaURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", initURL, mediaURLs, dashMimeType, nil
|
return "", initURL, mediaURLs, "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename string, quality string, allowFallback bool) (string, error) {
|
func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename string, quality string, allowFallback bool) (string, error) {
|
||||||
@@ -837,7 +684,7 @@ func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilename string, quality string, refreshed bool) (string, error) {
|
func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilename string, quality string, refreshed bool) (string, error) {
|
||||||
apis, err := getConfiguredTidalAPIAttemptList()
|
apis, err := GetRotatedTidalAPIList()
|
||||||
if err != nil && len(apis) == 0 {
|
if err != nil && len(apis) == 0 {
|
||||||
return "", fmt.Errorf("failed to load tidal api list: %w", err)
|
return "", fmt.Errorf("failed to load tidal api list: %w", err)
|
||||||
}
|
}
|
||||||
@@ -859,16 +706,29 @@ func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilena
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := downloader.DownloadFile(downloadURL, outputFilename, quality); err != nil {
|
if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
cleanupTidalDownloadArtifacts(outputFilename)
|
cleanupTidalDownloadArtifacts(outputFilename)
|
||||||
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err))
|
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := RememberTidalAPIUsage(apiURL); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to persist last used Tidal API: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
return apiURL, nil
|
return apiURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !refreshed {
|
||||||
|
if _, refreshErr := RefreshTidalAPIList(true); refreshErr != nil {
|
||||||
|
errors = append(errors, fmt.Sprintf("gist refresh failed: %v", refreshErr))
|
||||||
|
} else {
|
||||||
|
fmt.Println("All cached Tidal APIs failed, refreshed gist list and retrying...")
|
||||||
|
return t.tryDownloadAcrossTidalAPIs(trackID, outputFilename, quality, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if lastErr == nil {
|
if lastErr == nil {
|
||||||
lastErr = fmt.Errorf("all tidal apis failed")
|
lastErr = fmt.Errorf("all tidal apis failed")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -20,7 +20,6 @@
|
|||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
@@ -56,4 +55,4 @@
|
|||||||
"typescript-eslint": "^8.56.1",
|
"typescript-eslint": "^8.56.1",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
8864b4f7b7971b624d1ba25030f2db4e
|
867c45db7982e126a7249d80210f23be
|
||||||
Generated
-3
@@ -32,9 +32,6 @@ importers:
|
|||||||
'@radix-ui/react-select':
|
'@radix-ui/react-select':
|
||||||
specifier: ^2.2.6
|
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)
|
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':
|
'@radix-ui/react-slot':
|
||||||
specifier: ^1.2.4
|
specifier: ^1.2.4
|
||||||
version: 1.2.4(@types/react@19.2.14)(react@19.2.4)
|
version: 1.2.4(@types/react@19.2.14)(react@19.2.4)
|
||||||
|
|||||||
+14
-17
@@ -24,16 +24,15 @@ import { AudioResamplerPage } from "@/components/AudioResamplerPage";
|
|||||||
import { FileManagerPage } from "@/components/FileManagerPage";
|
import { FileManagerPage } from "@/components/FileManagerPage";
|
||||||
import { SettingsPage } from "@/components/SettingsPage";
|
import { SettingsPage } from "@/components/SettingsPage";
|
||||||
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
|
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
|
||||||
import { OtherProjects } from "@/components/OtherProjects";
|
import { AboutPage } from "@/components/AboutPage";
|
||||||
import { HistoryPage } from "@/components/HistoryPage";
|
import { HistoryPage } from "@/components/HistoryPage";
|
||||||
import { SupportPage } from "@/components/SupportPage";
|
|
||||||
import type { HistoryItem } from "@/components/FetchHistory";
|
import type { HistoryItem } from "@/components/FetchHistory";
|
||||||
import { useDownload } from "@/hooks/useDownload";
|
import { useDownload } from "@/hooks/useDownload";
|
||||||
import { useMetadata } from "@/hooks/useMetadata";
|
import { useMetadata } from "@/hooks/useMetadata";
|
||||||
import { useLyrics } from "@/hooks/useLyrics";
|
import { useLyrics } from "@/hooks/useLyrics";
|
||||||
import { useCover } from "@/hooks/useCover";
|
import { useCover } from "@/hooks/useCover";
|
||||||
import { useAvailability } from "@/hooks/useAvailability";
|
import { useAvailability } from "@/hooks/useAvailability";
|
||||||
import { ensureApiStatusCheckStarted } from "@/lib/api-status";
|
import { ensureSpotiFLACNextStatusCheckStarted } from "@/lib/api-status";
|
||||||
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
|
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
|
||||||
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
||||||
import { buildPlaylistFolderName } from "@/lib/playlist";
|
import { buildPlaylistFolderName } from "@/lib/playlist";
|
||||||
@@ -163,7 +162,7 @@ function App() {
|
|||||||
if (savedSettings) {
|
if (savedSettings) {
|
||||||
applyThemeMode(savedSettings.themeMode);
|
applyThemeMode(savedSettings.themeMode);
|
||||||
applyTheme(savedSettings.theme);
|
applyTheme(savedSettings.theme);
|
||||||
applyFont(savedSettings.fontFamily, savedSettings.customFonts);
|
applyFont(savedSettings.fontFamily);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -171,7 +170,7 @@ function App() {
|
|||||||
const settings = await loadSettings();
|
const settings = await loadSettings();
|
||||||
applyThemeMode(settings.themeMode);
|
applyThemeMode(settings.themeMode);
|
||||||
applyTheme(settings.theme);
|
applyTheme(settings.theme);
|
||||||
applyFont(settings.fontFamily, settings.customFonts);
|
applyFont(settings.fontFamily);
|
||||||
if (!settings.downloadPath) {
|
if (!settings.downloadPath) {
|
||||||
const settingsWithDefaults = await getSettingsWithDefaults();
|
const settingsWithDefaults = await getSettingsWithDefaults();
|
||||||
await saveSettings(settingsWithDefaults);
|
await saveSettings(settingsWithDefaults);
|
||||||
@@ -199,7 +198,7 @@ function App() {
|
|||||||
};
|
};
|
||||||
mediaQuery.addEventListener("change", handleChange);
|
mediaQuery.addEventListener("change", handleChange);
|
||||||
checkForUpdates();
|
checkForUpdates();
|
||||||
ensureApiStatusCheckStarted();
|
ensureSpotiFLACNextStatusCheckStarted();
|
||||||
void loadHistory();
|
void loadHistory();
|
||||||
return () => {
|
return () => {
|
||||||
mediaQuery.removeEventListener("change", handleChange);
|
mediaQuery.removeEventListener("change", handleChange);
|
||||||
@@ -447,7 +446,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
if ("album_info" in metadata.metadata) {
|
if ("album_info" in metadata.metadata) {
|
||||||
const { album_info, track_list } = 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} 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) => {
|
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) => {
|
||||||
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
||||||
setSpotifyUrl(pendingArtistUrl);
|
setSpotifyUrl(pendingArtistUrl);
|
||||||
const artistUrl = await metadata.handleArtistClick(artist);
|
const artistUrl = await metadata.handleArtistClick(artist);
|
||||||
@@ -465,7 +464,7 @@ function App() {
|
|||||||
const { playlist_info, track_list } = metadata.metadata;
|
const { playlist_info, track_list } = metadata.metadata;
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
const playlistFolderName = buildPlaylistFolderName(playlist_info.owner.name, playlist_info.owner.display_name, settings.playlistOwnerFolderName);
|
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} 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) => {
|
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) => {
|
||||||
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
||||||
setSpotifyUrl(pendingArtistUrl);
|
setSpotifyUrl(pendingArtistUrl);
|
||||||
const artistUrl = await metadata.handleArtistClick(artist);
|
const artistUrl = await metadata.handleArtistClick(artist);
|
||||||
@@ -481,7 +480,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
if ("artist_info" in metadata.metadata) {
|
if ("artist_info" in metadata.metadata) {
|
||||||
const { artist_info, album_list, track_list } = 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} 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) => {
|
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) => {
|
||||||
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
||||||
setSpotifyUrl(pendingArtistUrl);
|
setSpotifyUrl(pendingArtistUrl);
|
||||||
const artistUrl = await metadata.handleArtistClick(artist);
|
const artistUrl = await metadata.handleArtistClick(artist);
|
||||||
@@ -513,7 +512,7 @@ function App() {
|
|||||||
const savedSettings = getSettings();
|
const savedSettings = getSettings();
|
||||||
applyThemeMode(savedSettings.themeMode);
|
applyThemeMode(savedSettings.themeMode);
|
||||||
applyTheme(savedSettings.theme);
|
applyTheme(savedSettings.theme);
|
||||||
applyFont(savedSettings.fontFamily, savedSettings.customFonts);
|
applyFont(savedSettings.fontFamily);
|
||||||
if (pendingPageChange) {
|
if (pendingPageChange) {
|
||||||
setCurrentPage(pendingPageChange);
|
setCurrentPage(pendingPageChange);
|
||||||
setPendingPageChange(null);
|
setPendingPageChange(null);
|
||||||
@@ -529,10 +528,8 @@ function App() {
|
|||||||
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn}/>;
|
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn}/>;
|
||||||
case "debug":
|
case "debug":
|
||||||
return <DebugLoggerPage />;
|
return <DebugLoggerPage />;
|
||||||
case "projects":
|
case "about":
|
||||||
return <OtherProjects />;
|
return <AboutPage />;
|
||||||
case "support":
|
|
||||||
return <SupportPage />;
|
|
||||||
case "history":
|
case "history":
|
||||||
return <HistoryPage onHistorySelect={(cachedData) => {
|
return <HistoryPage onHistorySelect={(cachedData) => {
|
||||||
metadata.loadFromCache(cachedData);
|
metadata.loadFromCache(cachedData);
|
||||||
@@ -554,7 +551,7 @@ function App() {
|
|||||||
|
|
||||||
|
|
||||||
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
|
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
|
||||||
<DialogContent className="sm:max-w-106.25 p-6 [&>button]:hidden">
|
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
||||||
<div className="absolute right-4 top-4">
|
<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)}>
|
<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"/>
|
<X className="h-4 w-4"/>
|
||||||
@@ -627,7 +624,7 @@ function App() {
|
|||||||
|
|
||||||
|
|
||||||
<Dialog open={showUnsavedChangesDialog} onOpenChange={setShowUnsavedChangesDialog}>
|
<Dialog open={showUnsavedChangesDialog} onOpenChange={setShowUnsavedChangesDialog}>
|
||||||
<DialogContent className="sm:max-w-106.25 [&>button]:hidden">
|
<DialogContent className="sm:max-w-[425px] [&>button]:hidden">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Unsaved Changes</DialogTitle>
|
<DialogTitle>Unsaved Changes</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@@ -674,7 +671,7 @@ function App() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={isFFmpegInstalled === false} onOpenChange={() => { }}>
|
<Dialog open={isFFmpegInstalled === false} onOpenChange={() => { }}>
|
||||||
<DialogContent className="max-w-112.5 [&>button]:hidden p-6 gap-5">
|
<DialogContent className="max-w-[450px] [&>button]:hidden p-6 gap-5">
|
||||||
<DialogHeader className="space-y-2">
|
<DialogHeader className="space-y-2">
|
||||||
<DialogTitle className="text-lg font-bold tracking-tight">
|
<DialogTitle className="text-lg font-bold tracking-tight">
|
||||||
FFmpeg Required
|
FFmpeg Required
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
<?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>
|
|
||||||
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -1,11 +0,0 @@
|
|||||||
<?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>
|
|
||||||
|
Before Width: | Height: | Size: 735 B |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -1,18 +1,24 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { openExternal } from "@/lib/utils";
|
import { openExternal } from "@/lib/utils";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
|
||||||
import { Star, GitFork, Clock, Download, Info } from "lucide-react";
|
import { Star, GitFork, Clock, Download, Blocks, Heart, Copy, CircleCheck, Info } from "lucide-react";
|
||||||
import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
|
import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
|
||||||
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
|
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
|
||||||
import XIcon from "@/assets/x.webp";
|
import XIcon from "@/assets/x.webp";
|
||||||
|
import XProIcon from "@/assets/x-pro.webp";
|
||||||
import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
|
import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
|
||||||
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
|
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
|
||||||
import SpotiFLACNextIcon from "@/assets/icons/next.svg";
|
import SpotiFLACNextIcon from "@/assets/icons/next.svg";
|
||||||
|
import KofiLogo from "@/assets/ko-fi.gif";
|
||||||
|
import KofiSvg from "@/assets/kofi_symbol.svg";
|
||||||
|
import UsdtBarcode from "@/assets/usdt.jpg";
|
||||||
import { langColors } from "@/assets/github-lang-colors";
|
import { langColors } from "@/assets/github-lang-colors";
|
||||||
const browserExtensionItems = [
|
const browserExtensionItems = [
|
||||||
{ icon: AudioTTSProIcon, label: "AudioTTS Pro", alt: "AudioTTS Pro" },
|
{ icon: AudioTTSProIcon, label: "AudioTTS Pro", alt: "AudioTTS Pro" },
|
||||||
{ icon: ChatGPTTTSIcon, label: "ChatGPT TTS", alt: "ChatGPT TTS" },
|
{ icon: ChatGPTTTSIcon, label: "ChatGPT TTS", alt: "ChatGPT TTS" },
|
||||||
{ icon: XIcon, label: "Twitter/X Media Batch Downloader", alt: "Twitter/X Media Batch Downloader" },
|
{ icon: XIcon, label: "Twitter/X Media Batch Downloader", alt: "Twitter/X Media Batch Downloader" },
|
||||||
|
{ icon: XProIcon, label: "Twitter/X Media Batch Downloader Pro", alt: "Twitter/X Media Batch Downloader Pro" },
|
||||||
];
|
];
|
||||||
const projectCardClass = "cursor-pointer gap-3 py-5 transition-colors hover:bg-muted/50 dark:hover:bg-accent/50";
|
const projectCardClass = "cursor-pointer gap-3 py-5 transition-colors hover:bg-muted/50 dark:hover:bg-accent/50";
|
||||||
const projectCardHeaderClass = "px-5 gap-1.5";
|
const projectCardHeaderClass = "px-5 gap-1.5";
|
||||||
@@ -20,8 +26,10 @@ const projectCardContentClass = "px-5";
|
|||||||
const projectBodyClass = "text-[13px] leading-snug";
|
const projectBodyClass = "text-[13px] leading-snug";
|
||||||
const releaseMetaClass = "text-xs text-muted-foreground whitespace-nowrap";
|
const releaseMetaClass = "text-xs text-muted-foreground whitespace-nowrap";
|
||||||
const releaseVersionClass = "text-xs bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold whitespace-nowrap";
|
const releaseVersionClass = "text-xs bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold whitespace-nowrap";
|
||||||
export function OtherProjects() {
|
export function AboutPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState<"projects" | "support">("projects");
|
||||||
const [repoStats, setRepoStats] = useState<Record<string, any>>({});
|
const [repoStats, setRepoStats] = useState<Record<string, any>>({});
|
||||||
|
const [copiedUsdt, setCopiedUsdt] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchRepoStats = async () => {
|
const fetchRepoStats = async () => {
|
||||||
const CACHE_KEY = "github_repo_stats_v4";
|
const CACHE_KEY = "github_repo_stats_v4";
|
||||||
@@ -173,10 +181,24 @@ export function OtherProjects() {
|
|||||||
};
|
};
|
||||||
return (<div className="flex flex-col space-y-3">
|
return (<div className="flex flex-col space-y-3">
|
||||||
<div className="flex items-center justify-between shrink-0">
|
<div className="flex items-center justify-between shrink-0">
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Other Projects</h2>
|
<h2 className="text-2xl font-bold tracking-tight">About</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 pr-1.5">
|
<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="grid gap-2 grid-cols-3">
|
<div className="grid gap-2 grid-cols-3">
|
||||||
<Card className={projectCardClass} onClick={() => openExternal("https://github.com/spotbye/SpotiFLAC-Next")}>
|
<Card className={projectCardClass} onClick={() => openExternal("https://github.com/spotbye/SpotiFLAC-Next")}>
|
||||||
<CardHeader className={projectCardHeaderClass}>
|
<CardHeader className={projectCardHeaderClass}>
|
||||||
@@ -227,7 +249,7 @@ export function OtherProjects() {
|
|||||||
Note
|
Note
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs leading-snug text-sky-700 dark:text-sky-300">
|
<p className="text-xs leading-snug text-sky-700 dark:text-sky-300">
|
||||||
This project released as a token of appreciation for those who have supported SpotiFLAC through Ko-fi, Patreon, or crypto. It’s not a paid product, but it’s shared privately through a supporter-only post.
|
This project was created as a thank-you to everyone who has supported SpotiFLAC on Ko-fi.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>)}
|
</CardContent>)}
|
||||||
@@ -291,12 +313,12 @@ export function OtherProjects() {
|
|||||||
</CardContent>)}
|
</CardContent>)}
|
||||||
</Card>
|
</Card>
|
||||||
<div className="flex h-full flex-col gap-1.5">
|
<div className="flex h-full flex-col gap-1.5">
|
||||||
<Card className={`${projectCardClass} flex-1`} onClick={() => openExternal("https://exyezed.fyi/")}>
|
<Card className={`${projectCardClass} flex-1`} onClick={() => openExternal("https://exyezed.qzz.io/")}>
|
||||||
<CardHeader className={projectCardHeaderClass}>
|
<CardHeader className={projectCardHeaderClass}>
|
||||||
<CardTitle className="leading-tight">Browser Extensions & Scripts</CardTitle>
|
<CardTitle className="leading-tight">Browser Extensions & Scripts</CardTitle>
|
||||||
<CardDescription className="flex flex-col gap-2.5 pt-1.5">
|
<CardDescription className="flex flex-col gap-2.5 pt-1.5">
|
||||||
{browserExtensionItems.map((item) => (<div key={item.alt} className="flex items-center gap-2.5">
|
{browserExtensionItems.map((item) => (<div key={item.alt} className="flex items-center gap-2.5">
|
||||||
<img src={item.icon} className="h-5.5 w-5.5 rounded-sm shadow-sm" alt={item.alt}/>
|
<img src={item.icon} className="h-[22px] w-[22px] rounded-sm shadow-sm" alt={item.alt}/>
|
||||||
<span className={`${projectBodyClass} text-muted-foreground`}>
|
<span className={`${projectBodyClass} text-muted-foreground`}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
@@ -317,6 +339,55 @@ export function OtherProjects() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
{activeTab === "support" && (<div className="flex flex-col items-center justify-center p-4 space-y-6">
|
||||||
|
<div className="flex flex-col md:flex-row w-full max-w-3xl bg-card rounded-xl border shadow-sm">
|
||||||
|
|
||||||
|
<div className="flex-1 p-6 flex flex-col items-center justify-between border-b md:border-b-0 md:border-r space-y-6">
|
||||||
|
<div className="flex flex-col items-center space-y-4">
|
||||||
|
<div className="h-32 flex items-center justify-center w-full relative">
|
||||||
|
<img src={KofiLogo} className="w-72 absolute pointer-events-none" alt="Ko-fi"/>
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold text-foreground">Support via Ko-fi</h4>
|
||||||
|
<p className="text-sm text-muted-foreground text-center px-4">
|
||||||
|
Enjoying the project? You can support ongoing development by buying me a coffee.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button className="h-10 w-full text-sm font-semibold text-white gap-2 group bg-[#72a4f2] hover:bg-[#5f8cd6]" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
||||||
|
<img src={KofiSvg} className="w-5 h-5 shrink-0" alt="" aria-hidden="true"/>
|
||||||
|
Support me on Ko-fi
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex-1 p-6 flex flex-col items-center justify-between space-y-6">
|
||||||
|
<div className="flex flex-col items-center space-y-4 w-full">
|
||||||
|
<div className="h-32 flex items-center justify-center">
|
||||||
|
<div className="p-2 bg-white rounded-xl shadow-sm border">
|
||||||
|
<img src={UsdtBarcode} className="w-24 h-24 object-contain" alt="USDT Barcode"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold text-foreground">USDT (TRC20)</h4>
|
||||||
|
<p className="text-sm text-muted-foreground text-center px-4">
|
||||||
|
Crypto donations are also accepted. Scan the QR code or copy the address.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-muted/50 pl-3 pr-1.5 py-1.5 rounded-lg border w-full justify-between h-10">
|
||||||
|
<code className="text-xs font-mono text-muted-foreground truncate" title="THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs">
|
||||||
|
THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs
|
||||||
|
</code>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 hover:bg-background" onClick={() => {
|
||||||
|
navigator.clipboard.writeText("THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs");
|
||||||
|
setCopiedUsdt(true);
|
||||||
|
setTimeout(() => setCopiedUsdt(false), 500);
|
||||||
|
}}>
|
||||||
|
{copiedUsdt ? <CircleCheck className="h-3.5 w-3.5 text-green-500"/> : <Copy className="h-3.5 w-3.5"/>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
</div>);
|
</div>);
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,6 @@ interface AlbumInfoProps {
|
|||||||
isDownloading: boolean;
|
isDownloading: boolean;
|
||||||
bulkDownloadType: "all" | "selected" | null;
|
bulkDownloadType: "all" | "selected" | null;
|
||||||
downloadProgress: number;
|
downloadProgress: number;
|
||||||
downloadRemainingCount: number;
|
|
||||||
currentDownloadInfo: {
|
currentDownloadInfo: {
|
||||||
name: string;
|
name: string;
|
||||||
artists: string;
|
artists: string;
|
||||||
@@ -78,7 +77,7 @@ interface AlbumInfoProps {
|
|||||||
onTrackClick?: (track: TrackMetadata) => void;
|
onTrackClick?: (track: TrackMetadata) => void;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
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) {
|
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) {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
const albumArtistNames = splitArtistNames(albumInfo.artists);
|
const albumArtistNames = splitArtistNames(albumInfo.artists);
|
||||||
const artistSeparator = albumInfo.artists.includes(";") ? "; " : ", ";
|
const artistSeparator = albumInfo.artists.includes(";") ? "; " : ", ";
|
||||||
@@ -271,7 +270,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
</div>
|
</div>
|
||||||
{isDownloading && (<DownloadProgress progress={downloadProgress} remainingCount={downloadRemainingCount} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PlugZap, CheckCircle2, Loader2, Wrench } from "lucide-react";
|
import { SearchCheck, CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
||||||
import { TidalIcon, QobuzIcon, AmazonIcon, AppleMusicIcon, DeezerIcon } from "./PlatformIcons";
|
import { TidalIcon, QobuzIcon, AmazonIcon, MusicBrainzIcon, AppleMusicIcon, DeezerIcon } from "./PlatformIcons";
|
||||||
import { useApiStatus } from "@/hooks/useApiStatus";
|
import { useApiStatus } from "@/hooks/useApiStatus";
|
||||||
import { SPOTIFLAC_NEXT_SOURCES } from "@/lib/api-status";
|
import { SPOTIFLAC_NEXT_SOURCES } from "@/lib/api-status";
|
||||||
function renderStatusIndicator(status: "checking" | "online" | "offline" | "idle") {
|
function renderStatusIcon(status: "checking" | "online" | "offline" | "idle") {
|
||||||
if (status === "online") {
|
if (status === "online") {
|
||||||
return <CheckCircle2 className="h-5 w-5 text-emerald-500"/>;
|
return <CheckCircle2 className="h-5 w-5 text-emerald-500"/>;
|
||||||
}
|
}
|
||||||
if (status === "offline") {
|
if (status === "offline") {
|
||||||
return <Wrench className="h-4 w-4 text-amber-600 dark:text-amber-400"/>;
|
return <XCircle className="h-5 w-5 text-destructive"/>;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -19,6 +19,9 @@ function renderPlatformIcon(type: string) {
|
|||||||
if (type === "amazon") {
|
if (type === "amazon") {
|
||||||
return <AmazonIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
return <AmazonIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
||||||
}
|
}
|
||||||
|
if (type === "musicbrainz") {
|
||||||
|
return <MusicBrainzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
||||||
|
}
|
||||||
if (type === "deezer") {
|
if (type === "deezer") {
|
||||||
return <DeezerIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
return <DeezerIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
||||||
}
|
}
|
||||||
@@ -28,30 +31,27 @@ function renderPlatformIcon(type: string) {
|
|||||||
return <QobuzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
return <QobuzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
||||||
}
|
}
|
||||||
export function ApiStatusTab() {
|
export function ApiStatusTab() {
|
||||||
const { sources, statuses, nextStatuses, checkingSources, checkAllCurrent, checkAllNext } = useApiStatus();
|
const { sources, statuses, nextStatuses, checkingSources, checkOne } = useApiStatus();
|
||||||
const isCheckingCurrent = sources.some((source) => checkingSources[source.id] === true);
|
|
||||||
const isCheckingNext = SPOTIFLAC_NEXT_SOURCES.some((source) => nextStatuses[source.id] === "checking");
|
|
||||||
return (<div className="space-y-6">
|
return (<div className="space-y-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Services</h3>
|
||||||
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC</h3>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => void checkAllCurrent()} disabled={isCheckingCurrent} className="gap-2">
|
|
||||||
{isCheckingCurrent ? <Loader2 className="h-4 w-4 animate-spin"/> : <PlugZap className="h-4 w-4"/>}
|
|
||||||
Check
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{sources.map((source) => {
|
{sources.map((source) => {
|
||||||
const status = statuses[source.id] || "idle";
|
const status = statuses[source.id] || "idle";
|
||||||
|
const isChecking = checkingSources[source.id] === true;
|
||||||
return (<div key={source.id} className="space-y-4 p-4 border rounded-lg bg-card text-card-foreground shadow-sm">
|
return (<div key={source.id} className="space-y-4 p-4 border rounded-lg bg-card text-card-foreground shadow-sm">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{renderPlatformIcon(source.type)}
|
{renderPlatformIcon(source.type)}
|
||||||
<p className="font-medium leading-none">{source.name}</p>
|
<p className="font-medium leading-none">{source.name}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">{renderStatusIndicator(status)}</div>
|
<div className="flex items-center">{renderStatusIcon(status)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void checkOne(source.id)} disabled={isChecking} className="w-full gap-2">
|
||||||
|
{isChecking ? <Loader2 className="h-4 w-4 animate-spin"/> : <SearchCheck className="h-4 w-4"/>}
|
||||||
|
Check
|
||||||
|
</Button>
|
||||||
</div>);
|
</div>);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -60,13 +60,7 @@ export function ApiStatusTab() {
|
|||||||
<div className="border-t"/>
|
<div className="border-t"/>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Next Services</h3>
|
||||||
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Next</h3>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => void checkAllNext()} disabled={isCheckingNext} className="gap-2">
|
|
||||||
{isCheckingNext ? <Loader2 className="h-4 w-4 animate-spin"/> : <PlugZap className="h-4 w-4"/>}
|
|
||||||
Check
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
{SPOTIFLAC_NEXT_SOURCES.map((source) => {
|
{SPOTIFLAC_NEXT_SOURCES.map((source) => {
|
||||||
@@ -76,7 +70,7 @@ export function ApiStatusTab() {
|
|||||||
{renderPlatformIcon(source.id)}
|
{renderPlatformIcon(source.id)}
|
||||||
<p className="font-medium leading-none">{source.name}</p>
|
<p className="font-medium leading-none">{source.name}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">{renderStatusIndicator(status)}</div>
|
<div className="flex items-center">{renderStatusIcon(status)}</div>
|
||||||
</div>);
|
</div>);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ interface ArtistInfoProps {
|
|||||||
isDownloading: boolean;
|
isDownloading: boolean;
|
||||||
bulkDownloadType: "all" | "selected" | null;
|
bulkDownloadType: "all" | "selected" | null;
|
||||||
downloadProgress: number;
|
downloadProgress: number;
|
||||||
downloadRemainingCount: number;
|
|
||||||
currentDownloadInfo: {
|
currentDownloadInfo: {
|
||||||
name: string;
|
name: string;
|
||||||
artists: string;
|
artists: string;
|
||||||
@@ -96,7 +95,7 @@ interface ArtistInfoProps {
|
|||||||
onTrackClick?: (track: TrackMetadata) => void;
|
onTrackClick?: (track: TrackMetadata) => void;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
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) {
|
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) {
|
||||||
const [downloadingHeader, setDownloadingHeader] = useState(false);
|
const [downloadingHeader, setDownloadingHeader] = useState(false);
|
||||||
const [downloadingAvatar, setDownloadingAvatar] = useState(false);
|
const [downloadingAvatar, setDownloadingAvatar] = useState(false);
|
||||||
const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null);
|
const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null);
|
||||||
@@ -326,7 +325,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
{artistInfo.header ? (<>
|
{artistInfo.header ? (<>
|
||||||
<div className="relative w-full h-64 bg-cover bg-center">
|
<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-cover bg-center" style={{ backgroundImage: `url(${artistInfo.header})` }}/>
|
||||||
<div className="absolute inset-0 bg-linear-to-t from-black via-black/50 to-transparent"/>
|
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent"/>
|
||||||
{onBack && (<div className="absolute top-4 right-4 z-10">
|
{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">
|
<Button variant="ghost" size="icon" onClick={onBack} className="text-white hover:bg-white/20 hover:text-white">
|
||||||
<XCircle className="h-5 w-5"/>
|
<XCircle className="h-5 w-5"/>
|
||||||
@@ -564,7 +563,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
Filter Albums
|
Filter Albums
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-125 h-[80vh] flex flex-col">
|
<DialogContent className="sm:max-w-[500px] h-[80vh] flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Select Albums</DialogTitle>
|
<DialogTitle>Select Albums</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -635,7 +634,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isDownloading && (<DownloadProgress progress={downloadProgress} remainingCount={downloadRemainingCount} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||||
<SearchAndSort searchQuery={searchQuery} sortBy={sortBy} onSearchChange={onSearchChange} onSortChange={onSortChange}/>
|
<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}/>
|
<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>)}
|
</div>)}
|
||||||
|
|||||||
@@ -3,17 +3,14 @@ import { Progress } from "@/components/ui/progress";
|
|||||||
import { StopCircle } from "lucide-react";
|
import { StopCircle } from "lucide-react";
|
||||||
interface DownloadProgressProps {
|
interface DownloadProgressProps {
|
||||||
progress: number;
|
progress: number;
|
||||||
remainingCount?: number;
|
|
||||||
currentTrack: {
|
currentTrack: {
|
||||||
name: string;
|
name: string;
|
||||||
artists: string;
|
artists: string;
|
||||||
} | null;
|
} | null;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
}
|
}
|
||||||
export function DownloadProgress({ progress, remainingCount = 0, currentTrack, onStop }: DownloadProgressProps) {
|
export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) {
|
||||||
const clampedProgress = Math.min(100, Math.max(0, progress));
|
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">
|
return (<div className="w-full space-y-2 mt-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Progress value={clampedProgress} className="h-2 flex-1"/>
|
<Progress value={clampedProgress} className="h-2 flex-1"/>
|
||||||
@@ -23,7 +20,7 @@ export function DownloadProgress({ progress, remainingCount = 0, currentTrack, o
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{clampedProgress}% • {remainingLabel} -{" "}
|
{clampedProgress}% -{" "}
|
||||||
{currentTrack
|
{currentTrack
|
||||||
? `${currentTrack.name} - ${currentTrack.artists}`
|
? `${currentTrack.name} - ${currentTrack.artists}`
|
||||||
: "Preparing download..."}
|
: "Preparing download..."}
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, Pagi
|
|||||||
import { GetDownloadHistory, ClearDownloadHistory, GetPreviewURL, GetFetchHistory, DeleteDownloadHistoryItem, DeleteFetchHistoryItem, ClearFetchHistoryByType } from "../../wailsjs/go/main/App";
|
import { GetDownloadHistory, ClearDownloadHistory, GetPreviewURL, GetFetchHistory, DeleteDownloadHistoryItem, DeleteFetchHistoryItem, ClearFetchHistoryByType } from "../../wailsjs/go/main/App";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { openExternal } from "@/lib/utils";
|
import { openExternal } from "@/lib/utils";
|
||||||
import { getPreviewVolume } from "@/lib/preview";
|
import { SPOTIFY_PREVIEW_VOLUME } from "@/lib/preview";
|
||||||
import { createPreviewPlayback, type PreviewPlayback } from "@/lib/preview-player";
|
|
||||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||||
const formatDate = (timestamp: number) => {
|
const formatDate = (timestamp: number) => {
|
||||||
const date = new Date(timestamp * 1000);
|
const date = new Date(timestamp * 1000);
|
||||||
@@ -22,37 +21,6 @@ const formatDate = (timestamp: number) => {
|
|||||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
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 {
|
interface DownloadHistoryItem {
|
||||||
id: string;
|
id: string;
|
||||||
spotify_id: string;
|
spotify_id: string;
|
||||||
@@ -89,7 +57,7 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
const [downloadSortBy, setDownloadSortBy] = useState("default");
|
const [downloadSortBy, setDownloadSortBy] = useState("default");
|
||||||
const [downloadCurrentPage, setDownloadCurrentPage] = useState(1);
|
const [downloadCurrentPage, setDownloadCurrentPage] = useState(1);
|
||||||
const [playingPreviewId, setPlayingPreviewId] = useState<string | null>(null);
|
const [playingPreviewId, setPlayingPreviewId] = useState<string | null>(null);
|
||||||
const playbackRef = useRef<PreviewPlayback | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const [fetchHistory, setFetchHistory] = useState<FetchHistoryItem[]>([]);
|
const [fetchHistory, setFetchHistory] = useState<FetchHistoryItem[]>([]);
|
||||||
const [filteredFetchHistory, setFilteredFetchHistory] = useState<FetchHistoryItem[]>([]);
|
const [filteredFetchHistory, setFilteredFetchHistory] = useState<FetchHistoryItem[]>([]);
|
||||||
const [activeFetchTab, setActiveFetchTab] = useState("track");
|
const [activeFetchTab, setActiveFetchTab] = useState("track");
|
||||||
@@ -154,8 +122,9 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
playbackRef.current?.destroy();
|
if (audioRef.current) {
|
||||||
playbackRef.current = null;
|
audioRef.current.pause();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -211,35 +180,20 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
}, [fetchSearchQuery, activeFetchTab]);
|
}, [fetchSearchQuery, activeFetchTab]);
|
||||||
const handlePreview = async (id: string, spotifyId: string) => {
|
const handlePreview = async (id: string, spotifyId: string) => {
|
||||||
if (playingPreviewId === id) {
|
if (playingPreviewId === id) {
|
||||||
playbackRef.current?.destroy();
|
audioRef.current?.pause();
|
||||||
playbackRef.current = null;
|
|
||||||
setPlayingPreviewId(null);
|
setPlayingPreviewId(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (playbackRef.current) {
|
if (audioRef.current) {
|
||||||
playbackRef.current.destroy();
|
audioRef.current.pause();
|
||||||
playbackRef.current = null;
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const url = await GetPreviewURL(spotifyId);
|
const url = await GetPreviewURL(spotifyId);
|
||||||
if (url) {
|
if (url) {
|
||||||
const playback = await createPreviewPlayback(url, getPreviewVolume());
|
const audio = new Audio(url);
|
||||||
const audio = playback.audio;
|
audioRef.current = audio;
|
||||||
playbackRef.current = playback;
|
audio.volume = SPOTIFY_PREVIEW_VOLUME;
|
||||||
audio.onended = () => {
|
audio.onended = () => setPlayingPreviewId(null);
|
||||||
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();
|
audio.play();
|
||||||
setPlayingPreviewId(id);
|
setPlayingPreviewId(id);
|
||||||
}
|
}
|
||||||
@@ -317,7 +271,7 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
<Input placeholder="Search downloads..." value={downloadSearchQuery} onChange={(e) => setDownloadSearchQuery(e.target.value)} className="pl-8 h-9"/>
|
<Input placeholder="Search downloads..." value={downloadSearchQuery} onChange={(e) => setDownloadSearchQuery(e.target.value)} className="pl-8 h-9"/>
|
||||||
</div>
|
</div>
|
||||||
<Select value={downloadSortBy} onValueChange={setDownloadSortBy}>
|
<Select value={downloadSortBy} onValueChange={setDownloadSortBy}>
|
||||||
<SelectTrigger className="w-45 h-9">
|
<SelectTrigger className="w-[180px] h-9">
|
||||||
<ArrowUpDown className="mr-2 h-4 w-4"/>
|
<ArrowUpDown className="mr-2 h-4 w-4"/>
|
||||||
<SelectValue placeholder="Sort by"/>
|
<SelectValue placeholder="Sort by"/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -375,10 +329,10 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
||||||
<div className="truncate">{item.album}</div>
|
<div className="truncate">{item.album}</div>
|
||||||
</td>
|
</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">
|
<div className="flex flex-col items-start gap-1">
|
||||||
<span className="text-xs font-bold text-foreground">
|
<span className="text-xs font-bold text-foreground">
|
||||||
{getHistoryFormatLabel(item)}
|
{['HI_RES_LOSSLESS', 'LOSSLESS', 'flac', '6', '7', '27'].includes(item.format?.toLowerCase() || '') ? 'FLAC' : item.format?.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
{item.quality && <span className="text-[11px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>}
|
{item.quality && <span className="text-[11px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ interface PlaylistInfoProps {
|
|||||||
isDownloading: boolean;
|
isDownloading: boolean;
|
||||||
bulkDownloadType: "all" | "selected" | null;
|
bulkDownloadType: "all" | "selected" | null;
|
||||||
downloadProgress: number;
|
downloadProgress: number;
|
||||||
downloadRemainingCount: number;
|
|
||||||
currentDownloadInfo: {
|
currentDownloadInfo: {
|
||||||
name: string;
|
name: string;
|
||||||
artists: string;
|
artists: string;
|
||||||
@@ -89,7 +88,7 @@ interface PlaylistInfoProps {
|
|||||||
onTrackClick: (track: TrackMetadata) => void;
|
onTrackClick: (track: TrackMetadata) => void;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
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) {
|
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) {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
const playlistName = playlistInfo.owner.name;
|
const playlistName = playlistInfo.owner.name;
|
||||||
const playlistFolderName = buildPlaylistFolderName(playlistName, playlistInfo.owner.display_name, settings.playlistOwnerFolderName);
|
const playlistFolderName = buildPlaylistFolderName(playlistName, playlistInfo.owner.display_name, settings.playlistOwnerFolderName);
|
||||||
@@ -236,7 +235,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
</div>
|
</div>
|
||||||
{isDownloading && (<DownloadProgress progress={downloadProgress} remainingCount={downloadRemainingCount} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,18 +6,18 @@ import { ActivityIcon, type ActivityIconHandle } from "@/components/ui/activity"
|
|||||||
import { TerminalIcon } from "@/components/ui/terminal";
|
import { TerminalIcon } from "@/components/ui/terminal";
|
||||||
import { FileMusicIcon, type FileMusicIconHandle } from "@/components/ui/file-music";
|
import { FileMusicIcon, type FileMusicIconHandle } from "@/components/ui/file-music";
|
||||||
import { FilePenIcon, type FilePenIconHandle } from "@/components/ui/file-pen";
|
import { FilePenIcon, type FilePenIconHandle } from "@/components/ui/file-pen";
|
||||||
import { BugReportIcon } from "@/components/ui/bug-report-icon";
|
|
||||||
import { CoffeeIcon } from "@/components/ui/coffee";
|
import { CoffeeIcon } from "@/components/ui/coffee";
|
||||||
|
import { BadgeAlertIcon } from "@/components/ui/badge-alert";
|
||||||
|
import { GithubIcon } from "@/components/ui/github";
|
||||||
import { BlocksIcon } from "@/components/ui/blocks-icon";
|
import { BlocksIcon } from "@/components/ui/blocks-icon";
|
||||||
import { AudioLinesIcon, type AudioLinesIconHandle } from "@/components/ui/audio-lines";
|
import { AudioLinesIcon, type AudioLinesIconHandle } from "@/components/ui/audio-lines";
|
||||||
import { ToolCaseIcon } from "@/components/ui/tool-case";
|
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { openExternal } from "@/lib/utils";
|
import { openExternal } from "@/lib/utils";
|
||||||
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "projects" | "support" | "history";
|
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "about" | "history";
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
currentPage: PageType;
|
currentPage: PageType;
|
||||||
onPageChange: (page: PageType) => void;
|
onPageChange: (page: PageType) => void;
|
||||||
@@ -100,7 +100,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant={["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`}>
|
<Button variant={["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`}>
|
||||||
<ToolCaseIcon size={20}/>
|
<BlocksIcon size={20} loop={true}/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -134,7 +134,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => setIsIssuesDialogOpen(true)}>
|
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => setIsIssuesDialogOpen(true)}>
|
||||||
<BugReportIcon size={20} loop={true}/>
|
<GithubIcon size={20}/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
@@ -176,23 +176,23 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
|
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant={currentPage === "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")}>
|
<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")}>
|
||||||
<BlocksIcon size={20} loop={true}/>
|
<BadgeAlertIcon size={20}/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
<p>Other Projects</p>
|
<p>About</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<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")}>
|
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
||||||
<CoffeeIcon size={20} loop={true}/>
|
<CoffeeIcon size={20} loop={true}/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
<p>Support Me</p>
|
<p>Support me on Ko-fi</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { CircleCheck, Copy } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { openExternal } from "@/lib/utils";
|
|
||||||
import KofiLogo from "@/assets/ko-fi.gif";
|
|
||||||
import KofiSvg from "@/assets/kofi_symbol.svg";
|
|
||||||
import PatreonLogo from "@/assets/patreon.svg";
|
|
||||||
import PatreonSymbol from "@/assets/patreon_symbol.svg";
|
|
||||||
import UsdtBarcode from "@/assets/usdt.jpg";
|
|
||||||
|
|
||||||
export function SupportPage() {
|
|
||||||
const [copiedUsdt, setCopiedUsdt] = useState(false);
|
|
||||||
const [copiedEmail, setCopiedEmail] = useState(false);
|
|
||||||
return (<div className="flex flex-col space-y-3">
|
|
||||||
<div className="flex items-center justify-between shrink-0">
|
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Support Me</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center justify-center p-4">
|
|
||||||
<div className="grid w-full max-w-5xl overflow-hidden rounded-xl border bg-card shadow-sm md:grid-cols-3">
|
|
||||||
<div className="flex min-h-[22rem] flex-col items-center justify-between space-y-6 border-b p-6 md:border-b-0 md:border-r">
|
|
||||||
<div className="flex flex-col items-center space-y-4">
|
|
||||||
<div className="h-32 flex items-center justify-center w-full relative">
|
|
||||||
<img src={KofiLogo} className="w-72 absolute pointer-events-none" alt="Ko-fi"/>
|
|
||||||
</div>
|
|
||||||
<h4 className="font-semibold text-foreground">Support via Ko-fi</h4>
|
|
||||||
<p className="text-sm text-muted-foreground text-center px-4">
|
|
||||||
Buy me a coffee to help keep development going.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button className="h-10 w-full gap-2 bg-[#72a4f2] text-sm font-semibold text-white hover:bg-[#5f8cd6]" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
|
||||||
<img src={KofiSvg} className="h-6 w-6 shrink-0" alt="" aria-hidden="true"/>
|
|
||||||
Support me on Ko-fi
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex min-h-[22rem] flex-col items-center justify-between space-y-6 border-b p-6 md:border-b-0 md:border-r">
|
|
||||||
<div className="flex flex-col items-center space-y-4 w-full">
|
|
||||||
<div className="h-32 flex items-center justify-center w-full px-4">
|
|
||||||
<img src={PatreonLogo} className="w-56 max-w-full brightness-0 dark:brightness-100" alt="Patreon"/>
|
|
||||||
</div>
|
|
||||||
<h4 className="font-semibold text-foreground">Support via Patreon</h4>
|
|
||||||
<p className="text-sm text-muted-foreground text-center px-4">
|
|
||||||
Join on Patreon to help fund the project and follow updates.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button className="h-10 w-full gap-2 bg-[#ff424d] text-sm font-semibold text-white hover:bg-[#e63945]" onClick={() => openExternal("https://www.patreon.com/cw/afkarxyz")}>
|
|
||||||
<img src={PatreonSymbol} className="h-5 w-5 shrink-0" alt="" aria-hidden="true"/>
|
|
||||||
Support me on Patreon
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex min-h-[22rem] flex-col items-center justify-between space-y-6 p-6">
|
|
||||||
<div className="flex flex-col items-center space-y-4 w-full">
|
|
||||||
<div className="h-32 flex items-center justify-center">
|
|
||||||
<div className="rounded-xl border bg-white p-2 shadow-sm">
|
|
||||||
<img src={UsdtBarcode} className="h-24 w-24 object-contain" alt="USDT Barcode"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h4 className="font-semibold text-foreground">USDT (TRC20)</h4>
|
|
||||||
<p className="text-sm text-muted-foreground text-center px-4">
|
|
||||||
Prefer crypto? Use the QR code or wallet address below.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-10 w-full items-center justify-between gap-2 rounded-lg border bg-muted/50 py-1.5 pl-3 pr-1.5">
|
|
||||||
<code className="truncate text-xs font-mono text-muted-foreground" title="THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs">
|
|
||||||
THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs
|
|
||||||
</code>
|
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 hover:bg-background" onClick={() => {
|
|
||||||
navigator.clipboard.writeText("THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs");
|
|
||||||
setCopiedUsdt(true);
|
|
||||||
setTimeout(() => setCopiedUsdt(false), 500);
|
|
||||||
}}>
|
|
||||||
{copiedUsdt ? <CircleCheck className="h-3.5 w-3.5 text-green-500"/> : <Copy className="h-3.5 w-3.5"/>}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 w-full max-w-5xl rounded-xl border bg-muted/30 px-4 py-3 text-center text-sm text-muted-foreground">
|
|
||||||
If you have any questions or need help with donating, feel free to reach out via{" "}
|
|
||||||
<button type="button" className="font-medium text-foreground underline-offset-4 hover:underline" onClick={() => openExternal("https://t.me/afkarxyz")}>
|
|
||||||
Telegram
|
|
||||||
</button>{" "}
|
|
||||||
or{" "}
|
|
||||||
<button type="button" className="font-medium text-foreground underline-offset-4 hover:underline" onClick={() => {
|
|
||||||
navigator.clipboard.writeText("hi@afkarxyz.fyi");
|
|
||||||
setCopiedEmail(true);
|
|
||||||
setTimeout(() => setCopiedEmail(false), 500);
|
|
||||||
}}>
|
|
||||||
{copiedEmail ? "copied" : "hi@afkarxyz.fyi"}
|
|
||||||
</button>
|
|
||||||
.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>);
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
import { X, Minus, Maximize, SlidersHorizontal, Globe, Eye, EyeOff } from "lucide-react";
|
import { X, Minus, Maximize, SlidersHorizontal, Globe, Eye, EyeOff } from "lucide-react";
|
||||||
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
||||||
import { Menubar, MenubarContent, MenubarMenu, MenubarItem, MenubarTrigger, MenubarLabel, MenubarSeparator } from "@/components/ui/menubar";
|
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 { fetchCurrentIPInfo } from "@/lib/api";
|
||||||
import type { CurrentIPInfo } from "@/types/api";
|
import type { CurrentIPInfo } from "@/types/api";
|
||||||
import { openExternal } from "@/lib/utils";
|
import { openExternal } from "@/lib/utils";
|
||||||
@@ -27,12 +24,7 @@ const SPOTIFY_BLOCKED_COUNTRY_CODES = new Set([
|
|||||||
"TM",
|
"TM",
|
||||||
"YE",
|
"YE",
|
||||||
]);
|
]);
|
||||||
interface SettingsUpdatedDetail {
|
|
||||||
previewVolume?: number;
|
|
||||||
}
|
|
||||||
export function TitleBar() {
|
export function TitleBar() {
|
||||||
const initialSettings = getSettings();
|
|
||||||
const [previewVolume, setPreviewVolume] = useState(initialSettings.previewVolume ?? 100);
|
|
||||||
const [currentIPInfo, setCurrentIPInfo] = useState<CurrentIPInfo | null>(null);
|
const [currentIPInfo, setCurrentIPInfo] = useState<CurrentIPInfo | null>(null);
|
||||||
const [isLoadingCurrentIPInfo, setIsLoadingCurrentIPInfo] = useState(false);
|
const [isLoadingCurrentIPInfo, setIsLoadingCurrentIPInfo] = useState(false);
|
||||||
const [currentIPInfoError, setCurrentIPInfoError] = useState("");
|
const [currentIPInfoError, setCurrentIPInfoError] = useState("");
|
||||||
@@ -41,16 +33,6 @@ export function TitleBar() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
currentIPInfoRef.current = currentIPInfo;
|
currentIPInfoRef.current = currentIPInfo;
|
||||||
}, [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?: {
|
const loadCurrentIPInfo = async (options?: {
|
||||||
silent?: boolean;
|
silent?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -106,22 +88,6 @@ export function TitleBar() {
|
|||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
Quit();
|
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 detectedCountryCode = currentIPInfo?.country_code?.toUpperCase() || "";
|
||||||
const detectedFlagPath = detectedCountryCode ? `/assets/flags/${detectedCountryCode.toLowerCase()}.svg` : "";
|
const detectedFlagPath = detectedCountryCode ? `/assets/flags/${detectedCountryCode.toLowerCase()}.svg` : "";
|
||||||
const isSpotifyBlockedCountry = detectedCountryCode !== "" && SPOTIFY_BLOCKED_COUNTRY_CODES.has(detectedCountryCode);
|
const isSpotifyBlockedCountry = detectedCountryCode !== "" && SPOTIFY_BLOCKED_COUNTRY_CODES.has(detectedCountryCode);
|
||||||
@@ -136,17 +102,7 @@ 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">
|
<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"/>
|
<SlidersHorizontal className="w-3.5 h-3.5"/>
|
||||||
</MenubarTrigger>
|
</MenubarTrigger>
|
||||||
<MenubarContent align="end" className="min-w-70">
|
<MenubarContent align="end" className="min-w-[280px]">
|
||||||
<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">
|
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
||||||
<MenubarLabel className="p-0">Network</MenubarLabel>
|
<MenubarLabel className="p-0">Network</MenubarLabel>
|
||||||
{isSpotifyBlockedCountry && (<span className="text-xs font-medium text-destructive">
|
{isSpotifyBlockedCountry && (<span className="text-xs font-medium text-destructive">
|
||||||
@@ -156,7 +112,7 @@ export function TitleBar() {
|
|||||||
<div className="px-2 py-1.5 space-y-1">
|
<div className="px-2 py-1.5 space-y-1">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{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"/>)}
|
{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"/>)}
|
||||||
<span className="font-mono text-xs truncate">
|
<span className="font-mono text-xs truncate">
|
||||||
{isLoadingCurrentIPInfo
|
{isLoadingCurrentIPInfo
|
||||||
? "Detecting..."
|
? "Detecting..."
|
||||||
@@ -176,7 +132,7 @@ export function TitleBar() {
|
|||||||
</div>)}
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
<MenubarSeparator />
|
<MenubarSeparator />
|
||||||
<MenubarItem onClick={() => openExternal("https://afkarxyz.fyi")} className="gap-2">
|
<MenubarItem onClick={() => openExternal("https://afkarxyz.qzz.io")} className="gap-2">
|
||||||
<Globe className="w-4 h-4 opacity-70"/>
|
<Globe className="w-4 h-4 opacity-70"/>
|
||||||
<span>Website</span>
|
<span>Website</span>
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"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 };
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
"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 };
|
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"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 };
|
||||||
@@ -37,24 +37,14 @@ function SelectContent({ className, children, position = "popper", align = "cent
|
|||||||
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
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}/>);
|
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, indicatorPosition = "right", trailingAction, ...props }: React.ComponentProps<typeof SelectPrimitive.Item> & {
|
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
indicatorPosition?: "right" | "inline";
|
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}>
|
||||||
trailingAction?: React.ReactNode;
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
}) {
|
<SelectPrimitive.ItemIndicator>
|
||||||
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}>
|
<CheckIcon className="size-4"/>
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
</SelectPrimitive.ItemIndicator>
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
|
||||||
{indicatorPosition === "inline" && (<SelectPrimitive.ItemIndicator>
|
|
||||||
<CheckIcon className="size-4"/>
|
|
||||||
</SelectPrimitive.ItemIndicator>)}
|
|
||||||
</span>
|
</span>
|
||||||
{trailingAction ? (<span className="absolute right-2 flex items-center justify-center">
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
{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>);
|
</SelectPrimitive.Item>);
|
||||||
}
|
}
|
||||||
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
'use client';
|
|
||||||
import type { Variants } from 'motion/react';
|
|
||||||
import type { HTMLAttributes } from 'react';
|
|
||||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
|
||||||
import { motion, useAnimation } from 'motion/react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
export interface ToolCaseIconHandle {
|
|
||||||
startAnimation: () => void;
|
|
||||||
stopAnimation: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ToolCaseIconProps extends HTMLAttributes<HTMLDivElement> {
|
|
||||||
size?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DRAW_VARIANTS: Variants = {
|
|
||||||
normal: {
|
|
||||||
pathLength: 1,
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
animate: {
|
|
||||||
pathLength: [0, 1],
|
|
||||||
opacity: [0, 1],
|
|
||||||
transition: {
|
|
||||||
duration: 0.6,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const HANDLE_VARIANTS: Variants = {
|
|
||||||
normal: {
|
|
||||||
scaleX: 1,
|
|
||||||
originX: '50%',
|
|
||||||
},
|
|
||||||
animate: {
|
|
||||||
scaleX: [0.6, 1.1, 1],
|
|
||||||
originX: '50%',
|
|
||||||
transition: {
|
|
||||||
duration: 0.45,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const ToolCaseIcon = forwardRef<ToolCaseIconHandle, ToolCaseIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
|
||||||
const controls = useAnimation();
|
|
||||||
const isControlledRef = useRef(false);
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => {
|
|
||||||
isControlledRef.current = true;
|
|
||||||
return {
|
|
||||||
startAnimation: () => controls.start('animate'),
|
|
||||||
stopAnimation: () => controls.start('normal'),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (!isControlledRef.current) {
|
|
||||||
controls.start('animate');
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
onMouseEnter?.(e);
|
|
||||||
}
|
|
||||||
}, [controls, onMouseEnter]);
|
|
||||||
|
|
||||||
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (!isControlledRef.current) {
|
|
||||||
controls.start('normal');
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
onMouseLeave?.(e);
|
|
||||||
}
|
|
||||||
}, [controls, onMouseLeave]);
|
|
||||||
|
|
||||||
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<motion.path d="M10 15h4" variants={HANDLE_VARIANTS} animate={controls} initial="normal"/>
|
|
||||||
<motion.path d="m14.817 10.995-.971-1.45 1.034-1.232a2 2 0 0 0-2.025-3.238l-1.82.364L9.91 3.885a2 2 0 0 0-3.625.748L6.141 6.55l-1.725.426a2 2 0 0 0-.19 3.756l.657.27" variants={DRAW_VARIANTS} animate={controls} initial="normal"/>
|
|
||||||
<motion.path d="m18.822 10.995 2.26-5.38a1 1 0 0 0-.557-1.318L16.954 2.9a1 1 0 0 0-1.281.533l-.924 2.122" variants={DRAW_VARIANTS} animate={controls} initial="normal"/>
|
|
||||||
<motion.path d="M4 12.006A1 1 0 0 1 4.994 11H19a1 1 0 0 1 1 1v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" variants={DRAW_VARIANTS} animate={controls} initial="normal"/>
|
|
||||||
</svg>
|
|
||||||
</div>);
|
|
||||||
});
|
|
||||||
|
|
||||||
ToolCaseIcon.displayName = 'ToolCaseIcon';
|
|
||||||
|
|
||||||
export { ToolCaseIcon };
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { API_SOURCES, checkApiStatus, checkCurrentApiStatusesOnly, checkSpotiFLACNextStatusesOnly, getApiStatusState, subscribeApiStatus, } from "@/lib/api-status";
|
import { API_SOURCES, checkApiStatus, getApiStatusState, subscribeApiStatus, } from "@/lib/api-status";
|
||||||
export function useApiStatus() {
|
export function useApiStatus() {
|
||||||
const [state, setState] = useState(getApiStatusState);
|
const [state, setState] = useState(getApiStatusState);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -11,7 +11,5 @@ export function useApiStatus() {
|
|||||||
...state,
|
...state,
|
||||||
sources: API_SOURCES,
|
sources: API_SOURCES,
|
||||||
checkOne: (sourceId: string) => checkApiStatus(sourceId),
|
checkOne: (sourceId: string) => checkApiStatus(sourceId),
|
||||||
checkAllCurrent: () => checkCurrentApiStatusesOnly(),
|
|
||||||
checkAllNext: () => checkSpotiFLACNextStatusesOnly(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { downloadTrack, fetchSpotifyMetadata } from "@/lib/api";
|
import { downloadTrack, fetchSpotifyMetadata } from "@/lib/api";
|
||||||
import { getSettings, hasConfiguredCustomTidalApi, parseTemplate, sanitizeAutoOrder, type TemplateData } from "@/lib/settings";
|
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils";
|
import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
@@ -36,17 +36,13 @@ const GetTrackISRC = (spotifyId: string): Promise<string> => (window as any)["go
|
|||||||
async function resolveTemplateISRC(settings: {
|
async function resolveTemplateISRC(settings: {
|
||||||
folderTemplate?: string;
|
folderTemplate?: string;
|
||||||
filenameTemplate?: string;
|
filenameTemplate?: string;
|
||||||
existingFileCheckMode?: string;
|
|
||||||
}, spotifyId?: string): Promise<string> {
|
}, spotifyId?: string): Promise<string> {
|
||||||
if (!spotifyId) {
|
if (!spotifyId) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const folderTemplate = settings.folderTemplate || "";
|
const folderTemplate = settings.folderTemplate || "";
|
||||||
const filenameTemplate = settings.filenameTemplate || "";
|
const filenameTemplate = settings.filenameTemplate || "";
|
||||||
const shouldResolveISRC = settings.existingFileCheckMode === "isrc" ||
|
if (!folderTemplate.includes("{isrc}") && !filenameTemplate.includes("{isrc}")) {
|
||||||
folderTemplate.includes("{isrc}") ||
|
|
||||||
filenameTemplate.includes("{isrc}");
|
|
||||||
if (!shouldResolveISRC) {
|
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -56,18 +52,26 @@ async function resolveTemplateISRC(settings: {
|
|||||||
return "";
|
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" {
|
function getTidalAudioFormat(settings: any, mode: "single" | "auto"): "LOSSLESS" | "HI_RES_LOSSLESS" {
|
||||||
|
if (isTidalAltVariant(settings)) {
|
||||||
|
return "LOSSLESS";
|
||||||
|
}
|
||||||
if (mode === "auto") {
|
if (mode === "auto") {
|
||||||
return (settings.autoQuality || "24") === "24" ? "HI_RES_LOSSLESS" : "LOSSLESS";
|
return (settings.autoQuality || "24") === "24" ? "HI_RES_LOSSLESS" : "LOSSLESS";
|
||||||
}
|
}
|
||||||
return settings.tidalQuality || "LOSSLESS";
|
return settings.tidalQuality || "LOSSLESS";
|
||||||
}
|
}
|
||||||
function shouldFetchStreamingURLs(order: string[]): boolean {
|
function shouldFetchStreamingURLs(order: string[], settings: any): boolean {
|
||||||
return order.includes("amazon") || order.includes("tidal");
|
return order.includes("amazon") || (order.includes("tidal") && !isTidalAltVariant(settings));
|
||||||
}
|
}
|
||||||
export function useDownload(region: string) {
|
export function useDownload(region: string) {
|
||||||
const [downloadProgress, setDownloadProgress] = useState<number>(0);
|
const [downloadProgress, setDownloadProgress] = useState<number>(0);
|
||||||
const [downloadRemainingCount, setDownloadRemainingCount] = useState<number>(0);
|
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null);
|
const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null);
|
||||||
const [bulkDownloadType, setBulkDownloadType] = useState<"all" | "selected" | null>(null);
|
const [bulkDownloadType, setBulkDownloadType] = useState<"all" | "selected" | null>(null);
|
||||||
@@ -79,20 +83,10 @@ export function useDownload(region: string) {
|
|||||||
artists: string;
|
artists: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const shouldStopDownloadRef = useRef(false);
|
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 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 allowTidal = hasConfiguredCustomTidalApi(settings.customTidalApi);
|
const service = settings.downloader;
|
||||||
const service = settings.downloader === "tidal" && !allowTidal ? "auto" : settings.downloader;
|
|
||||||
const query = trackName && artistName ? `${trackName} ${artistName} ` : undefined;
|
const query = trackName && artistName ? `${trackName} ${artistName} ` : undefined;
|
||||||
const os = settings.operatingSystem;
|
const os = settings.operatingSystem;
|
||||||
const customTidalApi = allowTidal && typeof settings.customTidalApi === "string" && settings.customTidalApi.trim().startsWith("https://")
|
|
||||||
? settings.customTidalApi.trim().replace(/\/+$/g, "")
|
|
||||||
: undefined;
|
|
||||||
let outputDir = settings.downloadPath;
|
let outputDir = settings.downloadPath;
|
||||||
let useAlbumTrackNumber = false;
|
let useAlbumTrackNumber = false;
|
||||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||||
@@ -194,9 +188,11 @@ export function useDownload(region: string) {
|
|||||||
itemID = await AddToDownloadQueue(id, trackName || "", displayArtist || "", albumName || "");
|
itemID = await AddToDownloadQueue(id, trackName || "", displayArtist || "", albumName || "");
|
||||||
}
|
}
|
||||||
if (service === "auto") {
|
if (service === "auto") {
|
||||||
const order = sanitizeAutoOrder(settings.autoOrder, allowTidal).split("-");
|
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
||||||
|
const tidalVariant = getTidalVariant(settings);
|
||||||
|
const tidalLabel = tidalVariant === "alt" ? "Tidal Alt." : "Tidal";
|
||||||
let streamingURLs: any = null;
|
let streamingURLs: any = null;
|
||||||
if (spotifyId && shouldFetchStreamingURLs(order)) {
|
if (spotifyId && shouldFetchStreamingURLs(order, settings)) {
|
||||||
try {
|
try {
|
||||||
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
|
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
|
||||||
const urlsJson = await GetStreamingURLs(spotifyId, region);
|
const urlsJson = await GetStreamingURLs(spotifyId, region);
|
||||||
@@ -213,9 +209,9 @@ export function useDownload(region: string) {
|
|||||||
const is24Bit = (settings.autoQuality || "24") === "24";
|
const is24Bit = (settings.autoQuality || "24") === "24";
|
||||||
const qobuzQuality = is24Bit ? "27" : "6";
|
const qobuzQuality = is24Bit ? "27" : "6";
|
||||||
for (const s of order) {
|
for (const s of order) {
|
||||||
if (s === "tidal" && streamingURLs?.tidal_url) {
|
if (s === "tidal" && ((tidalVariant === "alt" && spotifyId) || streamingURLs?.tidal_url)) {
|
||||||
try {
|
try {
|
||||||
logger.debug(`trying Tidal for: ${trackName} - ${artistName}`);
|
logger.debug(`trying ${tidalLabel} for: ${trackName} - ${artistName}`);
|
||||||
const response = await downloadTrack({
|
const response = await downloadTrack({
|
||||||
service: "tidal",
|
service: "tidal",
|
||||||
query,
|
query,
|
||||||
@@ -233,11 +229,11 @@ export function useDownload(region: string) {
|
|||||||
spotify_id: spotifyId,
|
spotify_id: spotifyId,
|
||||||
embed_lyrics: settings.embedLyrics,
|
embed_lyrics: settings.embedLyrics,
|
||||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||||
service_url: streamingURLs?.tidal_url,
|
service_url: tidalVariant === "alt" ? undefined : streamingURLs?.tidal_url,
|
||||||
|
tidal_variant: tidalVariant,
|
||||||
duration: durationSeconds,
|
duration: durationSeconds,
|
||||||
item_id: itemID,
|
item_id: itemID,
|
||||||
audio_format: tidalQuality,
|
audio_format: tidalQuality,
|
||||||
tidal_api_url: customTidalApi,
|
|
||||||
spotify_track_number: spotifyTrackNumber,
|
spotify_track_number: spotifyTrackNumber,
|
||||||
spotify_disc_number: spotifyDiscNumber,
|
spotify_disc_number: spotifyDiscNumber,
|
||||||
spotify_total_tracks: spotifyTotalTracks,
|
spotify_total_tracks: spotifyTotalTracks,
|
||||||
@@ -250,17 +246,17 @@ export function useDownload(region: string) {
|
|||||||
embed_genre: settings.embedGenre,
|
embed_genre: settings.embedGenre,
|
||||||
});
|
});
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
logger.success(`Tidal: ${trackName} - ${artistName}`);
|
logger.success(`${tidalLabel}: ${trackName} - ${artistName}`);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
const errMsg = response.error || response.message || "Failed";
|
const errMsg = response.error || response.message || "Failed";
|
||||||
fallbackErrors.push(`[Tidal] ${errMsg}`);
|
fallbackErrors.push(`[${tidalLabel}] ${errMsg}`);
|
||||||
lastResponse = response;
|
lastResponse = response;
|
||||||
logger.warning(`Tidal failed, trying next...`);
|
logger.warning(`${tidalLabel} failed, trying next...`);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
logger.error(`Tidal error: ${err}`);
|
logger.error(`${tidalLabel} error: ${err}`);
|
||||||
fallbackErrors.push(`[Tidal] ${String(err)}`);
|
fallbackErrors.push(`[${tidalLabel}] ${String(err)}`);
|
||||||
lastResponse = { success: false, error: String(err) };
|
lastResponse = { success: false, error: String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -398,7 +394,7 @@ export function useDownload(region: string) {
|
|||||||
duration: durationSecondsForFallback,
|
duration: durationSecondsForFallback,
|
||||||
item_id: itemID,
|
item_id: itemID,
|
||||||
audio_format: audioFormat,
|
audio_format: audioFormat,
|
||||||
tidal_api_url: service === "tidal" ? customTidalApi : undefined,
|
tidal_variant: service === "tidal" ? getTidalVariant(settings) : undefined,
|
||||||
spotify_track_number: spotifyTrackNumber,
|
spotify_track_number: spotifyTrackNumber,
|
||||||
spotify_disc_number: spotifyDiscNumber,
|
spotify_disc_number: spotifyDiscNumber,
|
||||||
spotify_total_tracks: spotifyTotalTracks,
|
spotify_total_tracks: spotifyTotalTracks,
|
||||||
@@ -417,8 +413,7 @@ export function useDownload(region: string) {
|
|||||||
return singleServiceResponse;
|
return singleServiceResponse;
|
||||||
};
|
};
|
||||||
const downloadWithItemID = async (settings: any, itemID: string, trackName?: string, artistName?: string, albumName?: string, folderName?: string, position?: number, spotifyId?: string, durationMs?: number, isAlbum?: boolean, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
|
const downloadWithItemID = async (settings: any, itemID: string, trackName?: string, artistName?: string, albumName?: string, folderName?: string, position?: number, spotifyId?: string, durationMs?: number, isAlbum?: boolean, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
|
||||||
const allowTidal = hasConfiguredCustomTidalApi(settings.customTidalApi);
|
const service = settings.downloader;
|
||||||
const service = settings.downloader === "tidal" && !allowTidal ? "auto" : settings.downloader;
|
|
||||||
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
|
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
|
||||||
const os = settings.operatingSystem;
|
const os = settings.operatingSystem;
|
||||||
let outputDir = settings.downloadPath;
|
let outputDir = settings.downloadPath;
|
||||||
@@ -479,9 +474,11 @@ export function useDownload(region: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (service === "auto") {
|
if (service === "auto") {
|
||||||
const order = sanitizeAutoOrder(settings.autoOrder, allowTidal).split("-");
|
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
||||||
|
const tidalVariant = getTidalVariant(settings);
|
||||||
|
const tidalLabel = tidalVariant === "alt" ? "Tidal Alt." : "Tidal";
|
||||||
let streamingURLs: any = null;
|
let streamingURLs: any = null;
|
||||||
if (spotifyId && shouldFetchStreamingURLs(order)) {
|
if (spotifyId && shouldFetchStreamingURLs(order, settings)) {
|
||||||
try {
|
try {
|
||||||
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
|
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
|
||||||
const urlsJson = await GetStreamingURLs(spotifyId, region);
|
const urlsJson = await GetStreamingURLs(spotifyId, region);
|
||||||
@@ -498,9 +495,9 @@ export function useDownload(region: string) {
|
|||||||
const is24Bit = (settings.autoQuality || "24") === "24";
|
const is24Bit = (settings.autoQuality || "24") === "24";
|
||||||
const qobuzQuality = is24Bit ? "27" : "6";
|
const qobuzQuality = is24Bit ? "27" : "6";
|
||||||
for (const s of order) {
|
for (const s of order) {
|
||||||
if (s === "tidal" && streamingURLs?.tidal_url) {
|
if (s === "tidal" && ((tidalVariant === "alt" && spotifyId) || streamingURLs?.tidal_url)) {
|
||||||
try {
|
try {
|
||||||
logger.debug(`trying Tidal for: ${trackName} - ${artistName}`);
|
logger.debug(`trying ${tidalLabel} for: ${trackName} - ${artistName}`);
|
||||||
const response = await downloadTrack({
|
const response = await downloadTrack({
|
||||||
service: "tidal",
|
service: "tidal",
|
||||||
query,
|
query,
|
||||||
@@ -518,7 +515,8 @@ export function useDownload(region: string) {
|
|||||||
spotify_id: spotifyId,
|
spotify_id: spotifyId,
|
||||||
embed_lyrics: settings.embedLyrics,
|
embed_lyrics: settings.embedLyrics,
|
||||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||||
service_url: streamingURLs?.tidal_url,
|
service_url: tidalVariant === "alt" ? undefined : streamingURLs?.tidal_url,
|
||||||
|
tidal_variant: tidalVariant,
|
||||||
duration: durationSeconds,
|
duration: durationSeconds,
|
||||||
item_id: itemID,
|
item_id: itemID,
|
||||||
audio_format: tidalQuality,
|
audio_format: tidalQuality,
|
||||||
@@ -534,17 +532,17 @@ export function useDownload(region: string) {
|
|||||||
embed_genre: settings.embedGenre,
|
embed_genre: settings.embedGenre,
|
||||||
});
|
});
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
logger.success(`Tidal: ${trackName} - ${artistName}`);
|
logger.success(`${tidalLabel}: ${trackName} - ${artistName}`);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
const errMsg = response.error || response.message || "Failed";
|
const errMsg = response.error || response.message || "Failed";
|
||||||
fallbackErrors.push(`[Tidal] ${errMsg}`);
|
fallbackErrors.push(`[${tidalLabel}] ${errMsg}`);
|
||||||
lastResponse = response;
|
lastResponse = response;
|
||||||
logger.warning(`Tidal failed, trying next...`);
|
logger.warning(`${tidalLabel} failed, trying next...`);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
logger.error(`Tidal error: ${err}`);
|
logger.error(`${tidalLabel} error: ${err}`);
|
||||||
fallbackErrors.push(`[Tidal] ${String(err)}`);
|
fallbackErrors.push(`[${tidalLabel}] ${String(err)}`);
|
||||||
lastResponse = { success: false, error: String(err) };
|
lastResponse = { success: false, error: String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -681,6 +679,7 @@ export function useDownload(region: string) {
|
|||||||
duration: durationSecondsForFallback,
|
duration: durationSecondsForFallback,
|
||||||
item_id: itemID,
|
item_id: itemID,
|
||||||
audio_format: audioFormat,
|
audio_format: audioFormat,
|
||||||
|
tidal_variant: service === "tidal" ? getTidalVariant(settings) : undefined,
|
||||||
spotify_track_number: spotifyTrackNumber,
|
spotify_track_number: spotifyTrackNumber,
|
||||||
spotify_disc_number: spotifyDiscNumber,
|
spotify_disc_number: spotifyDiscNumber,
|
||||||
spotify_total_tracks: spotifyTotalTracks,
|
spotify_total_tracks: spotifyTotalTracks,
|
||||||
@@ -748,8 +747,6 @@ export function useDownload(region: string) {
|
|||||||
setIsDownloading(true);
|
setIsDownloading(true);
|
||||||
setBulkDownloadType("selected");
|
setBulkDownloadType("selected");
|
||||||
setDownloadProgress(0);
|
setDownloadProgress(0);
|
||||||
setDownloadRemainingCount(selectedTracks.length);
|
|
||||||
setCurrentDownloadInfo(null);
|
|
||||||
let outputDir = settings.downloadPath;
|
let outputDir = settings.downloadPath;
|
||||||
const os = settings.operatingSystem;
|
const os = settings.operatingSystem;
|
||||||
const useAlbumTag = settings.folderTemplate?.includes("{album}");
|
const useAlbumTag = settings.folderTemplate?.includes("{album}");
|
||||||
@@ -818,7 +815,7 @@ export function useDownload(region: string) {
|
|||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
let skippedCount = existingSpotifyIDs.size;
|
let skippedCount = existingSpotifyIDs.size;
|
||||||
const total = selectedTracks.length;
|
const total = selectedTracks.length;
|
||||||
updateBatchProgress(skippedCount, total);
|
setDownloadProgress(Math.round((skippedCount / total) * 100));
|
||||||
for (let i = 0; i < tracksToDownload.length; i++) {
|
for (let i = 0; i < tracksToDownload.length; i++) {
|
||||||
if (shouldStopDownloadRef.current) {
|
if (shouldStopDownloadRef.current) {
|
||||||
toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`);
|
toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`);
|
||||||
@@ -871,13 +868,12 @@ export function useDownload(region: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const completedCount = skippedCount + successCount + errorCount;
|
const completedCount = skippedCount + successCount + errorCount;
|
||||||
updateBatchProgress(completedCount, total);
|
setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
|
||||||
}
|
}
|
||||||
setDownloadingTrack(null);
|
setDownloadingTrack(null);
|
||||||
setCurrentDownloadInfo(null);
|
setCurrentDownloadInfo(null);
|
||||||
setIsDownloading(false);
|
setIsDownloading(false);
|
||||||
setBulkDownloadType(null);
|
setBulkDownloadType(null);
|
||||||
updateBatchProgress(0, 0);
|
|
||||||
shouldStopDownloadRef.current = false;
|
shouldStopDownloadRef.current = false;
|
||||||
const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App");
|
const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App");
|
||||||
await CancelAllQueuedItems();
|
await CancelAllQueuedItems();
|
||||||
@@ -926,8 +922,6 @@ export function useDownload(region: string) {
|
|||||||
setIsDownloading(true);
|
setIsDownloading(true);
|
||||||
setBulkDownloadType("all");
|
setBulkDownloadType("all");
|
||||||
setDownloadProgress(0);
|
setDownloadProgress(0);
|
||||||
setDownloadRemainingCount(tracksWithId.length);
|
|
||||||
setCurrentDownloadInfo(null);
|
|
||||||
let outputDir = settings.downloadPath;
|
let outputDir = settings.downloadPath;
|
||||||
const os = settings.operatingSystem;
|
const os = settings.operatingSystem;
|
||||||
const useAlbumTag = settings.folderTemplate?.includes("{album}");
|
const useAlbumTag = settings.folderTemplate?.includes("{album}");
|
||||||
@@ -991,7 +985,7 @@ export function useDownload(region: string) {
|
|||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
let skippedCount = existingSpotifyIDs.size;
|
let skippedCount = existingSpotifyIDs.size;
|
||||||
const total = tracksWithId.length;
|
const total = tracksWithId.length;
|
||||||
updateBatchProgress(skippedCount, total);
|
setDownloadProgress(Math.round((skippedCount / total) * 100));
|
||||||
for (let i = 0; i < tracksToDownload.length; i++) {
|
for (let i = 0; i < tracksToDownload.length; i++) {
|
||||||
if (shouldStopDownloadRef.current) {
|
if (shouldStopDownloadRef.current) {
|
||||||
toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`);
|
toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`);
|
||||||
@@ -1041,13 +1035,12 @@ export function useDownload(region: string) {
|
|||||||
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
|
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
const completedCount = skippedCount + successCount + errorCount;
|
const completedCount = skippedCount + successCount + errorCount;
|
||||||
updateBatchProgress(completedCount, total);
|
setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
|
||||||
}
|
}
|
||||||
setDownloadingTrack(null);
|
setDownloadingTrack(null);
|
||||||
setCurrentDownloadInfo(null);
|
setCurrentDownloadInfo(null);
|
||||||
setIsDownloading(false);
|
setIsDownloading(false);
|
||||||
setBulkDownloadType(null);
|
setBulkDownloadType(null);
|
||||||
updateBatchProgress(0, 0);
|
|
||||||
shouldStopDownloadRef.current = false;
|
shouldStopDownloadRef.current = false;
|
||||||
const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App");
|
const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App");
|
||||||
await CancelQueued();
|
await CancelQueued();
|
||||||
@@ -1094,7 +1087,6 @@ export function useDownload(region: string) {
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
downloadProgress,
|
downloadProgress,
|
||||||
downloadRemainingCount,
|
|
||||||
isDownloading,
|
isDownloading,
|
||||||
downloadingTrack,
|
downloadingTrack,
|
||||||
bulkDownloadType,
|
bulkDownloadType,
|
||||||
|
|||||||
@@ -9,17 +9,13 @@ const GetTrackISRC = (spotifyId: string): Promise<string> => (window as any)["go
|
|||||||
async function resolveTemplateISRC(settings: {
|
async function resolveTemplateISRC(settings: {
|
||||||
folderTemplate?: string;
|
folderTemplate?: string;
|
||||||
filenameTemplate?: string;
|
filenameTemplate?: string;
|
||||||
existingFileCheckMode?: string;
|
|
||||||
}, spotifyId?: string): Promise<string> {
|
}, spotifyId?: string): Promise<string> {
|
||||||
if (!spotifyId) {
|
if (!spotifyId) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const folderTemplate = settings.folderTemplate || "";
|
const folderTemplate = settings.folderTemplate || "";
|
||||||
const filenameTemplate = settings.filenameTemplate || "";
|
const filenameTemplate = settings.filenameTemplate || "";
|
||||||
const shouldResolveISRC = settings.existingFileCheckMode === "isrc" ||
|
if (!folderTemplate.includes("{isrc}") && !filenameTemplate.includes("{isrc}")) {
|
||||||
folderTemplate.includes("{isrc}") ||
|
|
||||||
filenameTemplate.includes("{isrc}");
|
|
||||||
if (!shouldResolveISRC) {
|
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,34 +1,32 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { GetPreviewURL } from "@/../wailsjs/go/main/App";
|
import { GetPreviewURL } from "@/../wailsjs/go/main/App";
|
||||||
import { getPreviewVolume } from "@/lib/preview";
|
import { SPOTIFY_PREVIEW_VOLUME } from "@/lib/preview";
|
||||||
import { createPreviewPlayback, type PreviewPlayback } from "@/lib/preview-player";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
export function usePreview() {
|
export function usePreview() {
|
||||||
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
|
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
|
||||||
|
const [currentAudio, setCurrentAudio] = useState<HTMLAudioElement | null>(null);
|
||||||
const [playingTrack, setPlayingTrack] = useState<string | 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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
stopCurrentAudio();
|
if (currentAudio) {
|
||||||
|
currentAudio.pause();
|
||||||
|
currentAudio.currentTime = 0;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, [currentAudio]);
|
||||||
const playPreview = async (trackId: string, trackName: string) => {
|
const playPreview = async (trackId: string, trackName: string) => {
|
||||||
try {
|
try {
|
||||||
const currentAudio = currentPlaybackRef.current?.audio;
|
|
||||||
if (playingTrack === trackId && currentAudio) {
|
if (playingTrack === trackId && currentAudio) {
|
||||||
stopCurrentAudio();
|
currentAudio.pause();
|
||||||
|
currentAudio.currentTime = 0;
|
||||||
setPlayingTrack(null);
|
setPlayingTrack(null);
|
||||||
|
setCurrentAudio(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentAudio) {
|
if (currentAudio) {
|
||||||
stopCurrentAudio();
|
currentAudio.pause();
|
||||||
|
currentAudio.currentTime = 0;
|
||||||
|
setCurrentAudio(null);
|
||||||
setPlayingTrack(null);
|
setPlayingTrack(null);
|
||||||
}
|
}
|
||||||
setLoadingPreview(trackId);
|
setLoadingPreview(trackId);
|
||||||
@@ -40,18 +38,15 @@ export function usePreview() {
|
|||||||
setLoadingPreview(null);
|
setLoadingPreview(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const playback = await createPreviewPlayback(previewURL, getPreviewVolume());
|
const audio = new Audio(previewURL);
|
||||||
const audio = playback.audio;
|
audio.volume = SPOTIFY_PREVIEW_VOLUME;
|
||||||
audio.addEventListener("loadeddata", () => {
|
audio.addEventListener("loadeddata", () => {
|
||||||
setLoadingPreview(null);
|
setLoadingPreview(null);
|
||||||
setPlayingTrack(trackId);
|
setPlayingTrack(trackId);
|
||||||
});
|
});
|
||||||
audio.addEventListener("ended", () => {
|
audio.addEventListener("ended", () => {
|
||||||
setPlayingTrack(null);
|
setPlayingTrack(null);
|
||||||
if (currentPlaybackRef.current?.audio === audio) {
|
setCurrentAudio(null);
|
||||||
currentPlaybackRef.current.destroy();
|
|
||||||
currentPlaybackRef.current = null;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
audio.addEventListener("error", () => {
|
audio.addEventListener("error", () => {
|
||||||
toast.error("Failed to play preview", {
|
toast.error("Failed to play preview", {
|
||||||
@@ -59,27 +54,27 @@ export function usePreview() {
|
|||||||
});
|
});
|
||||||
setLoadingPreview(null);
|
setLoadingPreview(null);
|
||||||
setPlayingTrack(null);
|
setPlayingTrack(null);
|
||||||
if (currentPlaybackRef.current?.audio === audio) {
|
setCurrentAudio(null);
|
||||||
currentPlaybackRef.current.destroy();
|
|
||||||
currentPlaybackRef.current = null;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
currentPlaybackRef.current = playback;
|
setCurrentAudio(audio);
|
||||||
await audio.play();
|
await audio.play();
|
||||||
}
|
}
|
||||||
catch (error: unknown) {
|
catch (error: any) {
|
||||||
stopCurrentAudio();
|
|
||||||
console.error("Preview error:", error);
|
console.error("Preview error:", error);
|
||||||
toast.error("Preview not available", {
|
toast.error("Preview not available", {
|
||||||
description: error instanceof Error ? error.message : `Could not load preview for "${trackName}"`,
|
description: error?.message || `Could not load preview for "${trackName}"`,
|
||||||
});
|
});
|
||||||
setLoadingPreview(null);
|
setLoadingPreview(null);
|
||||||
setPlayingTrack(null);
|
setPlayingTrack(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const stopPreview = () => {
|
const stopPreview = () => {
|
||||||
stopCurrentAudio();
|
if (currentAudio) {
|
||||||
setPlayingTrack(null);
|
currentAudio.pause();
|
||||||
|
currentAudio.currentTime = 0;
|
||||||
|
setCurrentAudio(null);
|
||||||
|
setPlayingTrack(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
playPreview,
|
playPreview,
|
||||||
|
|||||||
+68
-245
@@ -1,166 +1,84 @@
|
|||||||
import { CheckAPIStatus, CheckCustomTidalAPI } from "../../wailsjs/go/main/App";
|
import { CheckAPIStatus } from "../../wailsjs/go/main/App";
|
||||||
import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout";
|
import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout";
|
||||||
import { getSettings, hasConfiguredCustomTidalApi } from "@/lib/settings";
|
|
||||||
|
|
||||||
export type ApiCheckStatus = "checking" | "online" | "offline" | "idle";
|
export type ApiCheckStatus = "checking" | "online" | "offline" | "idle";
|
||||||
|
|
||||||
export interface ApiSource {
|
export interface ApiSource {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SpotiFLACNextSource {
|
interface SpotiFLACNextSource {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
statusKey?: string;
|
|
||||||
statusPrefix?: string;
|
|
||||||
}
|
}
|
||||||
|
type SpotiFLACNextStatusResponse = {
|
||||||
type SpotiFLACNextStatusResponse = Partial<Record<string, string>>;
|
tidal?: string;
|
||||||
type ApiStatusTargetReport = {
|
qobuz_a?: string;
|
||||||
target?: string;
|
qobuz_b?: string;
|
||||||
label?: string;
|
qobuz_c?: string;
|
||||||
online?: boolean;
|
deezer_a?: string;
|
||||||
message?: string;
|
deezer_b?: string;
|
||||||
|
amazon_a?: string;
|
||||||
|
amazon_b?: string;
|
||||||
|
amazon_c?: string;
|
||||||
|
apple?: string;
|
||||||
};
|
};
|
||||||
type ApiStatusReport = {
|
|
||||||
type?: string;
|
|
||||||
online?: boolean;
|
|
||||||
require_all?: boolean;
|
|
||||||
details?: ApiStatusTargetReport[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const API_SOURCES: ApiSource[] = [
|
export const API_SOURCES: ApiSource[] = [
|
||||||
{ id: "tidal", type: "tidal", name: "Tidal", url: "" },
|
{ id: "tidal", type: "tidal", name: "Tidal", url: "" },
|
||||||
{ id: "qobuz", type: "qobuz", name: "Qobuz", url: "" },
|
{ id: "qobuz", type: "qobuz", name: "Qobuz", url: "" },
|
||||||
{ id: "amazon", type: "amazon", name: "Amazon Music", url: "" },
|
{ id: "amazon", type: "amazon", name: "Amazon Music", url: "" },
|
||||||
|
{ id: "musicbrainz", type: "musicbrainz", name: "MusicBrainz", url: "https://musicbrainz.org" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const SPOTIFLAC_NEXT_SOURCES: SpotiFLACNextSource[] = [
|
export const SPOTIFLAC_NEXT_SOURCES: SpotiFLACNextSource[] = [
|
||||||
{ id: "tidal", name: "Tidal", statusKey: "tidal" },
|
{ id: "tidal", name: "Tidal" },
|
||||||
{ id: "qobuz", name: "Qobuz", statusPrefix: "qobuz_" },
|
{ id: "qobuz", name: "Qobuz" },
|
||||||
{ id: "amazon", name: "Amazon Music", statusPrefix: "amazon_" },
|
{ id: "amazon", name: "Amazon Music" },
|
||||||
{ id: "deezer", name: "Deezer", statusPrefix: "deezer_" },
|
{ id: "deezer", name: "Deezer" },
|
||||||
{ id: "apple", name: "Apple Music", statusKey: "apple" },
|
{ id: "apple", name: "Apple Music" },
|
||||||
];
|
];
|
||||||
|
const SPOTIFLAC_NEXT_STATUS_URL = "https://status.spotbye.qzz.io/status";
|
||||||
const SPOTIFLAC_STATUS_URL = "https://gist.githubusercontent.com/afkarxyz/6e57cd362cbd67f889e3a91a76254a5e/raw";
|
const SPOTIFLAC_NEXT_MAX_ATTEMPTS = 3;
|
||||||
const SPOTIFLAC_CURRENT_AMAZON_STATUS_KEY = "amazon_a";
|
const SPOTIFLAC_NEXT_RETRY_DELAY_MS = 1200;
|
||||||
const SPOTIFLAC_STATUS_MAX_ATTEMPTS = 3;
|
|
||||||
const SPOTIFLAC_STATUS_RETRY_DELAY_MS = 1200;
|
|
||||||
const CheckAPIStatusReport = (apiType: string, apiURL: string): Promise<ApiStatusReport> => (window as any)["go"]["main"]["App"]["CheckAPIStatusReport"](apiType, apiURL);
|
|
||||||
const LogStatusConsole = (level: string, message: string): Promise<void> => (window as any)["go"]["main"]["App"]["LogStatusConsole"](level, message);
|
|
||||||
|
|
||||||
type ApiStatusState = {
|
type ApiStatusState = {
|
||||||
checkingSources: Record<string, boolean>;
|
checkingSources: Record<string, boolean>;
|
||||||
statuses: Record<string, ApiCheckStatus>;
|
statuses: Record<string, ApiCheckStatus>;
|
||||||
nextStatuses: Record<string, ApiCheckStatus>;
|
nextStatuses: Record<string, ApiCheckStatus>;
|
||||||
};
|
};
|
||||||
|
|
||||||
let apiStatusState: ApiStatusState = {
|
let apiStatusState: ApiStatusState = {
|
||||||
checkingSources: {},
|
checkingSources: {},
|
||||||
statuses: {},
|
statuses: {},
|
||||||
nextStatuses: {},
|
nextStatuses: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
let activeCheckCurrentOnly: Promise<void> | null = null;
|
|
||||||
let activeCheckNextOnly: Promise<void> | null = null;
|
let activeCheckNextOnly: Promise<void> | null = null;
|
||||||
let activeStatusPayloadFetch: Promise<SpotiFLACNextStatusResponse> | null = null;
|
|
||||||
|
|
||||||
const activeSourceChecks = new Map<string, Promise<void>>();
|
const activeSourceChecks = new Map<string, Promise<void>>();
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
function emitApiStatusChange() {
|
function emitApiStatusChange() {
|
||||||
for (const listener of listeners) {
|
for (const listener of listeners) {
|
||||||
listener();
|
listener();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState) {
|
function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState) {
|
||||||
apiStatusState = updater(apiStatusState);
|
apiStatusState = updater(apiStatusState);
|
||||||
emitApiStatusChange();
|
emitApiStatusChange();
|
||||||
}
|
}
|
||||||
|
async function checkSourceStatus(source: ApiSource): Promise<ApiCheckStatus> {
|
||||||
function delay(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
function sendStatusConsole(level: "info" | "warning" | "error", message: string): void {
|
|
||||||
try {
|
try {
|
||||||
void LogStatusConsole(level, message);
|
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 {
|
catch {
|
||||||
return;
|
return "offline";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function logStatusInfo(message: string): void {
|
function statusFromNextValue(value: string | undefined): ApiCheckStatus {
|
||||||
sendStatusConsole("info", message);
|
return value === "up" ? "online" : "offline";
|
||||||
}
|
}
|
||||||
function logStatusWarning(message: string): void {
|
|
||||||
sendStatusConsole("warning", message);
|
|
||||||
}
|
|
||||||
function logStatusError(message: string): void {
|
|
||||||
sendStatusConsole("error", message);
|
|
||||||
}
|
|
||||||
function truncateStatusMessage(message?: string, maxLen = 180): string {
|
|
||||||
const trimmed = (message || "").trim();
|
|
||||||
if (trimmed.length <= maxLen) {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
return trimmed.slice(0, maxLen) + "...";
|
|
||||||
}
|
|
||||||
function logQobuzStatusReport(report: ApiStatusReport): void {
|
|
||||||
const details = Array.isArray(report.details) ? report.details : [];
|
|
||||||
if (details.length === 0) {
|
|
||||||
logStatusWarning("[Status][Qobuz] No provider details were returned.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const onlineCount = details.filter((detail) => detail.online === true).length;
|
|
||||||
logStatusInfo(`[Status][Qobuz] Provider check completed: ${onlineCount}/${details.length} providers online.`);
|
|
||||||
for (const detail of details) {
|
|
||||||
const label = detail.label || detail.target || "Unknown provider";
|
|
||||||
const suffix = detail.message ? ` - ${truncateStatusMessage(detail.message)}` : "";
|
|
||||||
if (detail.online) {
|
|
||||||
logStatusInfo(`[Status][Qobuz] ${label}: online${suffix}`);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
logStatusWarning(`[Status][Qobuz] ${label}: offline${suffix}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (report.online) {
|
|
||||||
logStatusInfo(`[Status][Qobuz] SpotiFLAC Qobuz is online (${onlineCount}/${details.length} providers online).`);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
logStatusWarning(`[Status][Qobuz] SpotiFLAC Qobuz marked maintenance because all ${details.length} providers are offline.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function anyNextVariantUp(values: Array<string | undefined>): ApiCheckStatus {
|
function anyNextVariantUp(values: Array<string | undefined>): ApiCheckStatus {
|
||||||
return values.some((value) => value === "up") ? "online" : "offline";
|
return values.some((value) => value === "up") ? "online" : "offline";
|
||||||
}
|
}
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
function getNextSourceValues(payload: SpotiFLACNextStatusResponse, source: SpotiFLACNextSource): string[] {
|
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||||
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> {
|
function getSafeNextStatusesFallback(currentStatuses: Record<string, ApiCheckStatus>): Record<string, ApiCheckStatus> {
|
||||||
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
|
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
|
||||||
const current = currentStatuses[source.id];
|
const current = currentStatuses[source.id];
|
||||||
@@ -168,142 +86,60 @@ function getSafeNextStatusesFallback(currentStatuses: Record<string, ApiCheckSta
|
|||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
}
|
}
|
||||||
|
async function fetchSpotiFLACNextStatusesOnce(): Promise<Record<string, ApiCheckStatus>> {
|
||||||
function hasCurrentResults(): boolean {
|
const response = await withTimeout(fetch(SPOTIFLAC_NEXT_STATUS_URL, {
|
||||||
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",
|
method: "GET",
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
},
|
},
|
||||||
}), CHECK_TIMEOUT_MS, "SpotiFLAC status check timed out after 10 seconds");
|
}), CHECK_TIMEOUT_MS, "SpotiFLAC Next status check timed out after 10 seconds");
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`SpotiFLAC status returned ${response.status}`);
|
throw new Error(`SpotiFLAC Next status returned ${response.status}`);
|
||||||
}
|
}
|
||||||
|
const payload = (await response.json()) as SpotiFLACNextStatusResponse;
|
||||||
return (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 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>> {
|
async function checkSpotiFLACNextStatuses(): Promise<Record<string, ApiCheckStatus>> {
|
||||||
const payload = await fetchSpotiFLACStatusPayload();
|
let lastError: unknown = null;
|
||||||
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
|
for (let attempt = 1; attempt <= SPOTIFLAC_NEXT_MAX_ATTEMPTS; attempt++) {
|
||||||
acc[source.id] = anyNextVariantUp(getNextSourceValues(payload, source));
|
try {
|
||||||
return acc;
|
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 {
|
export function getApiStatusState(): ApiStatusState {
|
||||||
return apiStatusState;
|
return apiStatusState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function subscribeApiStatus(listener: () => void): () => void {
|
export function subscribeApiStatus(listener: () => void): () => void {
|
||||||
listeners.add(listener);
|
listeners.add(listener);
|
||||||
return () => {
|
return () => {
|
||||||
listeners.delete(listener);
|
listeners.delete(listener);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
function hasSpotiFLACNextResults(): boolean {
|
||||||
export async function checkCurrentApiStatusesOnly(): Promise<void> {
|
return SPOTIFLAC_NEXT_SOURCES.some((source) => {
|
||||||
if (activeCheckCurrentOnly) {
|
const status = apiStatusState.nextStatuses[source.id];
|
||||||
return activeCheckCurrentOnly;
|
return status === "online" || status === "offline";
|
||||||
}
|
});
|
||||||
|
|
||||||
activeCheckCurrentOnly = (async () => {
|
|
||||||
await Promise.all(API_SOURCES.map((source) => checkApiStatus(source.id)));
|
|
||||||
})();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await activeCheckCurrentOnly;
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
activeCheckCurrentOnly = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
|
export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
|
||||||
if (activeCheckNextOnly) {
|
if (activeCheckNextOnly) {
|
||||||
return activeCheckNextOnly;
|
return activeCheckNextOnly;
|
||||||
}
|
}
|
||||||
|
|
||||||
activeCheckNextOnly = (async () => {
|
activeCheckNextOnly = (async () => {
|
||||||
const checkingNextStatuses = Object.fromEntries(SPOTIFLAC_NEXT_SOURCES.map((source) => [source.id, "checking" as ApiCheckStatus]));
|
const checkingNextStatuses = Object.fromEntries(SPOTIFLAC_NEXT_SOURCES.map((source) => [source.id, "checking" as ApiCheckStatus]));
|
||||||
setApiStatusState((current) => ({
|
setApiStatusState((current) => ({
|
||||||
@@ -313,8 +149,11 @@ export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
|
|||||||
...checkingNextStatuses,
|
...checkingNextStatuses,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
setApiStatusState((current) => ({
|
||||||
|
...current,
|
||||||
|
nextStatuses: { ...current.nextStatuses },
|
||||||
|
}));
|
||||||
const nextStatuses = await checkSpotiFLACNextStatuses();
|
const nextStatuses = await checkSpotiFLACNextStatuses();
|
||||||
setApiStatusState((current) => ({
|
setApiStatusState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -330,40 +169,26 @@ export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
|
|||||||
nextStatuses: getSafeNextStatusesFallback(current.nextStatuses),
|
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()) {
|
if (!activeCheckNextOnly && !hasSpotiFLACNextResults()) {
|
||||||
void checkSpotiFLACNextStatusesOnly();
|
void checkSpotiFLACNextStatusesOnly();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensureSpotiFLACNextStatusCheckStarted(): void {
|
|
||||||
ensureApiStatusCheckStarted();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkApiStatus(sourceId: string): Promise<void> {
|
export async function checkApiStatus(sourceId: string): Promise<void> {
|
||||||
const source = API_SOURCES.find((item) => item.id === sourceId);
|
const source = API_SOURCES.find((item) => item.id === sourceId);
|
||||||
if (!source) {
|
if (!source) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeCheck = activeSourceChecks.get(sourceId);
|
const activeCheck = activeSourceChecks.get(sourceId);
|
||||||
if (activeCheck) {
|
if (activeCheck) {
|
||||||
return activeCheck;
|
return activeCheck;
|
||||||
}
|
}
|
||||||
|
|
||||||
const task = (async () => {
|
const task = (async () => {
|
||||||
setApiStatusState((current) => ({
|
setApiStatusState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -376,7 +201,6 @@ export async function checkApiStatus(sourceId: string): Promise<void> {
|
|||||||
[sourceId]: "checking",
|
[sourceId]: "checking",
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await checkSourceStatus(source);
|
const status = await checkSourceStatus(source);
|
||||||
setApiStatusState((current) => ({
|
setApiStatusState((current) => ({
|
||||||
@@ -398,7 +222,6 @@ export async function checkApiStatus(sourceId: string): Promise<void> {
|
|||||||
activeSourceChecks.delete(sourceId);
|
activeSourceChecks.delete(sourceId);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
activeSourceChecks.set(sourceId, task);
|
activeSourceChecks.set(sourceId, task);
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export async function fetchSpotifyMetadata(url: string, batch: boolean = true, d
|
|||||||
}
|
}
|
||||||
export async function downloadTrack(request: DownloadRequest): Promise<DownloadResponse> {
|
export async function downloadTrack(request: DownloadRequest): Promise<DownloadResponse> {
|
||||||
const req = new main.DownloadRequest(request);
|
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) {
|
if (request.use_single_genre !== undefined) {
|
||||||
(req as any).use_single_genre = request.use_single_genre;
|
(req as any).use_single_genre = request.use_single_genre;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
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();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,10 +1 @@
|
|||||||
import { getSettings } from "@/lib/settings";
|
|
||||||
export const SPOTIFY_PREVIEW_VOLUME = 1;
|
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));
|
|
||||||
}
|
|
||||||
|
|||||||
+237
-605
@@ -1,32 +1,15 @@
|
|||||||
import { GetDefaults, LoadFonts as LoadFontsFromBackend, LoadSettings, SaveFonts as SaveFontsToBackend, SaveSettings as SaveToBackend, } from "../../wailsjs/go/main/App";
|
import { GetDefaults, LoadSettings, 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 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";
|
||||||
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 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 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 {
|
export interface Settings {
|
||||||
downloadPath: string;
|
downloadPath: string;
|
||||||
downloader: "auto" | "tidal" | "qobuz" | "amazon";
|
downloader: "auto" | "tidal" | "qobuz" | "amazon";
|
||||||
customTidalApi: string;
|
|
||||||
linkResolver: "songstats" | "songlink";
|
linkResolver: "songstats" | "songlink";
|
||||||
allowResolverFallback: boolean;
|
allowResolverFallback: boolean;
|
||||||
theme: string;
|
theme: string;
|
||||||
themeMode: "auto" | "light" | "dark";
|
themeMode: "auto" | "light" | "dark";
|
||||||
fontFamily: FontFamily;
|
fontFamily: FontFamily;
|
||||||
customFonts: CustomFontOption[];
|
|
||||||
folderPreset: FolderPreset;
|
folderPreset: FolderPreset;
|
||||||
folderTemplate: string;
|
folderTemplate: string;
|
||||||
filenamePreset: FilenamePreset;
|
filenamePreset: FilenamePreset;
|
||||||
@@ -39,6 +22,7 @@ export interface Settings {
|
|||||||
embedLyrics: boolean;
|
embedLyrics: boolean;
|
||||||
embedMaxQualityCover: boolean;
|
embedMaxQualityCover: boolean;
|
||||||
operatingSystem: "Windows" | "linux/MacOS";
|
operatingSystem: "Windows" | "linux/MacOS";
|
||||||
|
tidalVariant: "tidal" | "alt";
|
||||||
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
|
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
|
||||||
qobuzQuality: "6" | "7" | "27";
|
qobuzQuality: "6" | "7" | "27";
|
||||||
amazonQuality: "original";
|
amazonQuality: "original";
|
||||||
@@ -48,8 +32,6 @@ export interface Settings {
|
|||||||
createPlaylistFolder: boolean;
|
createPlaylistFolder: boolean;
|
||||||
playlistOwnerFolderName: boolean;
|
playlistOwnerFolderName: boolean;
|
||||||
createM3u8File: boolean;
|
createM3u8File: boolean;
|
||||||
previewVolume: number;
|
|
||||||
existingFileCheckMode: ExistingFileCheckMode;
|
|
||||||
useFirstArtistOnly: boolean;
|
useFirstArtistOnly: boolean;
|
||||||
useSingleGenre: boolean;
|
useSingleGenre: boolean;
|
||||||
embedGenre: boolean;
|
embedGenre: boolean;
|
||||||
@@ -60,105 +42,54 @@ export const FOLDER_PRESETS: Record<FolderPreset, {
|
|||||||
label: string;
|
label: string;
|
||||||
template: string;
|
template: string;
|
||||||
}> = {
|
}> = {
|
||||||
none: { label: "No Subfolder", template: "" },
|
"none": { label: "No Subfolder", template: "" },
|
||||||
artist: { label: "Artist", template: "{artist}" },
|
"artist": { label: "Artist", template: "{artist}" },
|
||||||
album: { label: "Album", template: "{album}" },
|
"album": { label: "Album", template: "{album}" },
|
||||||
"year-album": { label: "[Year] Album", template: "[{year}] {album}" },
|
"year-album": { label: "[Year] Album", template: "[{year}] {album}" },
|
||||||
"year-artist-album": {
|
"year-artist-album": { label: "[Year] Artist - Album", template: "[{year}] {artist} - {album}" },
|
||||||
label: "[Year] Artist - Album",
|
|
||||||
template: "[{year}] {artist} - {album}",
|
|
||||||
},
|
|
||||||
"artist-album": { label: "Artist / Album", template: "{artist}/{album}" },
|
"artist-album": { label: "Artist / Album", template: "{artist}/{album}" },
|
||||||
"artist-year-album": {
|
"artist-year-album": { label: "Artist / [Year] Album", template: "{artist}/[{year}] {album}" },
|
||||||
label: "Artist / [Year] Album",
|
"artist-year-nested-album": { label: "Artist / Year / Album", template: "{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": { label: "Album Artist", template: "{album_artist}" },
|
||||||
"album-artist-album": {
|
"album-artist-album": { label: "Album Artist / Album", template: "{album_artist}/{album}" },
|
||||||
label: "Album Artist / Album",
|
"album-artist-year-album": { label: "Album Artist / [Year] Album", template: "{album_artist}/[{year}] {album}" },
|
||||||
template: "{album_artist}/{album}",
|
"album-artist-year-nested-album": { label: "Album Artist / Year / Album", template: "{album_artist}/{year}/{album}" },
|
||||||
},
|
"year": { label: "Year", template: "{year}" },
|
||||||
"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}" },
|
"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, {
|
export const FILENAME_PRESETS: Record<FilenamePreset, {
|
||||||
label: string;
|
label: string;
|
||||||
template: string;
|
template: string;
|
||||||
}> = {
|
}> = {
|
||||||
title: { label: "Title", template: "{title}" },
|
"title": { label: "Title", template: "{title}" },
|
||||||
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
|
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
|
||||||
"artist-title": { label: "Artist - Title", template: "{artist} - {title}" },
|
"artist-title": { label: "Artist - Title", template: "{artist} - {title}" },
|
||||||
"track-title": { label: "Track. Title", template: "{track}. {title}" },
|
"track-title": { label: "Track. Title", template: "{track}. {title}" },
|
||||||
"track-title-artist": {
|
"track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" },
|
||||||
label: "Track. Title - Artist",
|
"track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" },
|
||||||
template: "{track}. {title} - {artist}",
|
"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}" },
|
||||||
"track-artist-title": {
|
"artist-album-title": { label: "Artist - Album - Title", template: "{artist} - {album} - {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}" },
|
"track-dash-title": { label: "Track - Title", template: "{track} - {title}" },
|
||||||
"disc-track-title": {
|
"disc-track-title": { label: "Disc-Track. Title", template: "{disc}-{track}. {title}" },
|
||||||
label: "Disc-Track. Title",
|
"disc-track-title-artist": { label: "Disc-Track. Title - Artist", template: "{disc}-{track}. {title} - {artist}" },
|
||||||
template: "{disc}-{track}. {title}",
|
"custom": { label: "Custom...", template: "{title} - {artist}" },
|
||||||
},
|
|
||||||
"disc-track-title-artist": {
|
|
||||||
label: "Disc-Track. Title - Artist",
|
|
||||||
template: "{disc}-{track}. {title} - {artist}",
|
|
||||||
},
|
|
||||||
custom: { label: "Custom...", template: "{title} - {artist}" },
|
|
||||||
};
|
};
|
||||||
export const TEMPLATE_VARIABLES = [
|
export const TEMPLATE_VARIABLES = [
|
||||||
{ key: "{title}", description: "Track title", example: "Shake It Off" },
|
{ key: "{title}", description: "Track title", example: "Shake It Off" },
|
||||||
{ key: "{artist}", description: "Track artist", example: "Taylor Swift" },
|
{ key: "{artist}", description: "Track artist", example: "Taylor Swift" },
|
||||||
{ key: "{album}", description: "Album name", example: "1989" },
|
{ 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: "{track}", description: "Track number", example: "01" },
|
||||||
{ key: "{disc}", description: "Disc number", example: "1" },
|
{ key: "{disc}", description: "Disc number", example: "1" },
|
||||||
{ key: "{year}", description: "Release year", example: "2014" },
|
{ key: "{year}", description: "Release year", example: "2014" },
|
||||||
{
|
{ key: "{date}", description: "Release date (YYYY-MM-DD)", example: "2014-10-27" },
|
||||||
key: "{date}",
|
{ key: "{isrc}", description: "Track ISRC", example: "USUM71412345" },
|
||||||
description: "Release date (YYYY-MM-DD)",
|
|
||||||
example: "2014-10-27",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "{isrc}",
|
|
||||||
description: "Track ISRC",
|
|
||||||
example: "USUM71412345",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
function detectOS(): "Windows" | "linux/MacOS" {
|
function detectOS(): "Windows" | "linux/MacOS" {
|
||||||
const platform = window.navigator.platform.toLowerCase();
|
const platform = window.navigator.platform.toLowerCase();
|
||||||
if (platform.includes("win")) {
|
if (platform.includes('win')) {
|
||||||
return "Windows";
|
return "Windows";
|
||||||
}
|
}
|
||||||
return "linux/MacOS";
|
return "linux/MacOS";
|
||||||
@@ -166,13 +97,11 @@ function detectOS(): "Windows" | "linux/MacOS" {
|
|||||||
export const DEFAULT_SETTINGS: Settings = {
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
downloadPath: "",
|
downloadPath: "",
|
||||||
downloader: "auto",
|
downloader: "auto",
|
||||||
customTidalApi: "",
|
|
||||||
linkResolver: "songlink",
|
linkResolver: "songlink",
|
||||||
allowResolverFallback: true,
|
allowResolverFallback: true,
|
||||||
theme: "yellow",
|
theme: "yellow",
|
||||||
themeMode: "auto",
|
themeMode: "auto",
|
||||||
fontFamily: "google-sans",
|
fontFamily: "google-sans",
|
||||||
customFonts: [],
|
|
||||||
folderPreset: "none",
|
folderPreset: "none",
|
||||||
folderTemplate: "",
|
folderTemplate: "",
|
||||||
filenamePreset: "title-artist",
|
filenamePreset: "title-artist",
|
||||||
@@ -182,500 +111,52 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
embedLyrics: false,
|
embedLyrics: false,
|
||||||
embedMaxQualityCover: false,
|
embedMaxQualityCover: false,
|
||||||
operatingSystem: detectOS(),
|
operatingSystem: detectOS(),
|
||||||
|
tidalVariant: "tidal",
|
||||||
tidalQuality: "LOSSLESS",
|
tidalQuality: "LOSSLESS",
|
||||||
qobuzQuality: "6",
|
qobuzQuality: "6",
|
||||||
amazonQuality: "original",
|
amazonQuality: "original",
|
||||||
autoOrder: "qobuz-amazon",
|
autoOrder: "tidal-qobuz-amazon",
|
||||||
autoQuality: "16",
|
autoQuality: "16",
|
||||||
allowFallback: true,
|
allowFallback: true,
|
||||||
createPlaylistFolder: true,
|
createPlaylistFolder: true,
|
||||||
playlistOwnerFolderName: false,
|
playlistOwnerFolderName: false,
|
||||||
createM3u8File: false,
|
createM3u8File: false,
|
||||||
previewVolume: 100,
|
|
||||||
existingFileCheckMode: "filename",
|
|
||||||
useFirstArtistOnly: false,
|
useFirstArtistOnly: false,
|
||||||
useSingleGenre: false,
|
useSingleGenre: false,
|
||||||
embedGenre: false,
|
embedGenre: false,
|
||||||
redownloadWithSuffix: false,
|
redownloadWithSuffix: false,
|
||||||
separator: "semicolon",
|
separator: "semicolon"
|
||||||
};
|
};
|
||||||
export const FONT_OPTIONS: FontOption[] = [
|
export const FONT_OPTIONS: {
|
||||||
{
|
value: FontFamily;
|
||||||
value: "bricolage-grotesque",
|
label: string;
|
||||||
label: "Bricolage Grotesque",
|
fontFamily: string;
|
||||||
fontFamily: '"Bricolage Grotesque", system-ui, sans-serif',
|
}[] = [
|
||||||
},
|
{ 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: "dm-sans",
|
{ value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
|
||||||
label: "DM Sans",
|
{ value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
|
||||||
fontFamily: '"DM Sans", 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: "figtree",
|
{ value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' },
|
||||||
label: "Figtree",
|
{ value: "noto-sans", label: "Noto Sans", fontFamily: '"Noto Sans", system-ui, sans-serif' },
|
||||||
fontFamily: '"Figtree", 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: "geist-sans",
|
{ value: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' },
|
||||||
label: "Geist Sans",
|
{ value: "public-sans", label: "Public Sans", fontFamily: '"Public Sans", system-ui, sans-serif' },
|
||||||
fontFamily: '"Geist", 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' },
|
||||||
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',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
const BUILT_IN_FONT_VALUES = new Set(FONT_OPTIONS.map((font) => font.value));
|
export function applyFont(fontFamily: FontFamily): void {
|
||||||
const GOOGLE_FONT_LINK_ID_PREFIX = "spotiflac-custom-font-";
|
const font = FONT_OPTIONS.find(f => f.value === fontFamily);
|
||||||
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) {
|
if (font) {
|
||||||
document.documentElement.style.setProperty("--font-sans", font.fontFamily);
|
document.documentElement.style.setProperty('--font-sans', font.fontFamily);
|
||||||
document.body.style.fontFamily = 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> {
|
async function fetchDefaultPath(): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const data = await GetDefaults();
|
const data = await GetDefaults();
|
||||||
@@ -686,11 +167,90 @@ async function fetchDefaultPath(): Promise<string> {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const SETTINGS_KEY = "spotiflac-settings";
|
||||||
|
let cachedSettings: Settings | null = null;
|
||||||
function getSettingsFromLocalStorage(): Settings {
|
function getSettingsFromLocalStorage(): Settings {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(SETTINGS_KEY);
|
const stored = localStorage.getItem(SETTINGS_KEY);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
return toNormalizedSettings(JSON.parse(stored) as SettingsPayload);
|
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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
@@ -699,25 +259,108 @@ function getSettingsFromLocalStorage(): Settings {
|
|||||||
return DEFAULT_SETTINGS;
|
return DEFAULT_SETTINGS;
|
||||||
}
|
}
|
||||||
export function getSettings(): Settings {
|
export function getSettings(): Settings {
|
||||||
if (cachedSettings) {
|
if (cachedSettings)
|
||||||
return cachedSettings;
|
return cachedSettings;
|
||||||
}
|
|
||||||
return getSettingsFromLocalStorage();
|
return getSettingsFromLocalStorage();
|
||||||
}
|
}
|
||||||
export async function loadSettings(): Promise<Settings> {
|
export async function loadSettings(): Promise<Settings> {
|
||||||
try {
|
try {
|
||||||
const backendSettings = await LoadSettings();
|
const backendSettings = await LoadSettings();
|
||||||
if (backendSettings) {
|
if (backendSettings) {
|
||||||
const parsed = backendSettings as SettingsPayload;
|
const parsed = backendSettings as any;
|
||||||
const customFonts = await loadStoredCustomFonts(parsed.customFonts);
|
if ('darkMode' in parsed && !('themeMode' in parsed)) {
|
||||||
cachedSettings = toNormalizedSettings({
|
parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
|
||||||
...parsed,
|
delete parsed.darkMode;
|
||||||
customFonts,
|
|
||||||
});
|
|
||||||
if ("customFonts" in parsed) {
|
|
||||||
await persistSettingsInternal(cachedSettings, false);
|
|
||||||
}
|
}
|
||||||
return cachedSettings;
|
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!;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
@@ -725,19 +368,12 @@ export async function loadSettings(): Promise<Settings> {
|
|||||||
}
|
}
|
||||||
const local = getSettingsFromLocalStorage();
|
const local = getSettingsFromLocalStorage();
|
||||||
try {
|
try {
|
||||||
const customFonts = await loadStoredCustomFonts(local.customFonts);
|
await SaveToBackend(local as any);
|
||||||
const localWithFonts = toNormalizedSettings({
|
cachedSettings = local;
|
||||||
...local,
|
|
||||||
customFonts,
|
|
||||||
});
|
|
||||||
await persistSettingsInternal(localWithFonts, false);
|
|
||||||
cachedSettings = localWithFonts;
|
|
||||||
return localWithFonts;
|
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
console.error("Failed to migrate settings to backend:", error);
|
console.error("Failed to migrate settings to backend:", error);
|
||||||
}
|
}
|
||||||
cachedSettings = local;
|
|
||||||
return local;
|
return local;
|
||||||
}
|
}
|
||||||
export interface TemplateData {
|
export interface TemplateData {
|
||||||
@@ -753,9 +389,8 @@ export interface TemplateData {
|
|||||||
playlist?: string;
|
playlist?: string;
|
||||||
}
|
}
|
||||||
export function parseTemplate(template: string, data: TemplateData): string {
|
export function parseTemplate(template: string, data: TemplateData): string {
|
||||||
if (!template) {
|
if (!template)
|
||||||
return "";
|
return "";
|
||||||
}
|
|
||||||
let result = template;
|
let result = template;
|
||||||
result = result.replace(/\{title\}/g, data.title || "Unknown Title");
|
result = result.replace(/\{title\}/g, data.title || "Unknown Title");
|
||||||
result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist");
|
result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist");
|
||||||
@@ -779,8 +414,10 @@ export async function getSettingsWithDefaults(): Promise<Settings> {
|
|||||||
}
|
}
|
||||||
export async function saveSettings(settings: Settings): Promise<void> {
|
export async function saveSettings(settings: Settings): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const normalizedSettings = toNormalizedSettings(settings as SettingsPayload);
|
cachedSettings = settings;
|
||||||
await persistSettingsInternal(normalizedSettings);
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||||
|
await SaveToBackend(settings as any);
|
||||||
|
window.dispatchEvent(new CustomEvent('settingsUpdated', { detail: settings }));
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
console.error("Failed to save settings:", error);
|
console.error("Failed to save settings:", error);
|
||||||
@@ -794,12 +431,7 @@ export async function updateSettings(partial: Partial<Settings>): Promise<Settin
|
|||||||
}
|
}
|
||||||
export async function resetToDefaultSettings(): Promise<Settings> {
|
export async function resetToDefaultSettings(): Promise<Settings> {
|
||||||
const defaultPath = await fetchDefaultPath();
|
const defaultPath = await fetchDefaultPath();
|
||||||
const customFonts = await loadCustomFonts();
|
const defaultSettings = { ...DEFAULT_SETTINGS, downloadPath: defaultPath };
|
||||||
const defaultSettings = {
|
|
||||||
...DEFAULT_SETTINGS,
|
|
||||||
downloadPath: defaultPath,
|
|
||||||
customFonts,
|
|
||||||
};
|
|
||||||
await saveSettings(defaultSettings);
|
await saveSettings(defaultSettings);
|
||||||
return defaultSettings;
|
return defaultSettings;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ export interface DownloadRequest {
|
|||||||
release_date?: string;
|
release_date?: string;
|
||||||
cover_url?: string;
|
cover_url?: string;
|
||||||
tidal_api_url?: string;
|
tidal_api_url?: string;
|
||||||
|
tidal_variant?: "tidal" | "alt";
|
||||||
output_dir?: string;
|
output_dir?: string;
|
||||||
audio_format?: string;
|
audio_format?: string;
|
||||||
folder_name?: string;
|
folder_name?: string;
|
||||||
|
|||||||
Vendored
+142
@@ -0,0 +1,142 @@
|
|||||||
|
// 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>;
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
// @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);
|
||||||
|
}
|
||||||
@@ -9,14 +9,13 @@ require (
|
|||||||
github.com/go-flac/go-flac v1.0.0
|
github.com/go-flac/go-flac v1.0.0
|
||||||
github.com/pquerna/otp v1.5.0
|
github.com/pquerna/otp v1.5.0
|
||||||
github.com/ulikunitz/xz v0.5.15
|
github.com/ulikunitz/xz v0.5.15
|
||||||
github.com/wailsapp/wails/v2 v2.12.0
|
github.com/wailsapp/wails/v2 v2.11.0
|
||||||
go.etcd.io/bbolt v1.4.3
|
go.etcd.io/bbolt v1.4.3
|
||||||
golang.org/x/image v0.12.0
|
golang.org/x/image v0.12.0
|
||||||
golang.org/x/text v0.31.0
|
golang.org/x/text v0.31.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
|
||||||
github.com/bep/debounce v1.2.1 // indirect
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
|
||||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
|
||||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
|
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
|
||||||
@@ -75,8 +73,8 @@ github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+
|
|||||||
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||||
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
|
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
||||||
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
|
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"productName": "SpotiFLAC",
|
"productName": "SpotiFLAC",
|
||||||
"productVersion": "7.1.7",
|
"productVersion": "7.1.5",
|
||||||
"copyright": "© 2026 afkarxyz"
|
"copyright": "© 2026 afkarxyz"
|
||||||
},
|
},
|
||||||
"wailsjsdir": "./frontend",
|
"wailsjsdir": "./frontend",
|
||||||
|
|||||||
Reference in New Issue
Block a user