Compare commits

..

77 Commits

Author SHA1 Message Date
afkarxyz 2f78f7e7c7 .cleanup 2026-04-19 23:14:12 +07:00
afkarxyz 7c52c2d9b4 .tidal alt 2026-04-19 23:12:06 +07:00
afkarxyz a24ca370eb .refine status check 2026-04-19 22:35:03 +07:00
afkarxyz 3af9327a3d .tidal gist url 2026-04-19 22:15:49 +07:00
afkarxyz a3e780587b .manual check 2026-04-19 21:54:56 +07:00
afkarxyz 043f3f07f3 .refine global scrollbar 2026-04-19 21:48:25 +07:00
afkarxyz bcea7a00bd .remove spotidownloader 2026-04-19 21:41:56 +07:00
afkarxyz e74808fb07 .update url 2026-04-18 07:59:13 +07:00
afkarxyz 17a75ea278 .arm64 linux 2026-04-18 07:52:20 +07:00
afkarxyz d6907641ed .revert build 2026-04-18 07:40:34 +07:00
afkarxyz e9b6b02db1 .readme 2026-04-14 07:36:00 +07:00
afkarxyz a9c52e7b6d .cleanup 2026-04-14 07:28:39 +07:00
afkarxyz ce1e6cc65a .build rename 2026-04-14 07:25:16 +07:00
afkarxyz f75081780e .unified status check 2026-04-14 07:14:18 +07:00
afkarxyz c0c1348c3f .skip off musicbrainz 2026-04-14 06:38:02 +07:00
afkarxyz f123caf5b0 .status icons update 2026-04-14 06:35:18 +07:00
afkarxyz 4c5bba73ce .rate limit musicbrainz 2026-04-14 06:06:12 +07:00
afkarxyz 1858fd6f12 .update ffmpeg + linux arm build 2026-04-14 05:49:23 +07:00
afkarxyz 42d25abe0c .rename 2026-04-13 23:30:43 +07:00
afkarxyz 927aad30e7 .move website below network info 2026-04-13 23:01:14 +07:00
afkarxyz 7320cfb6ca .failed fetch pop up 2026-04-13 23:00:10 +07:00
afkarxyz d85d3174e9 .refine ip info 2026-04-13 22:57:34 +07:00
afkarxyz eda188d4b0 .ip detection 2026-04-13 22:56:57 +07:00
afkarxyz 7997f7e264 .remove spotfetch api 2026-04-13 22:50:45 +07:00
afkarxyz e23fa2a48e .unified totp 2026-04-13 22:43:35 +07:00
afkarxyz 5a3f819cef .upc metadata 2026-04-13 22:39:58 +07:00
afkarxyz 66e3f0e572 .improved recent fetches 2026-04-13 22:18:08 +07:00
afkarxyz 2684bc54bd .improve fetch track list info 2026-04-13 22:07:34 +07:00
afkarxyz db8f82aa17 .redownlaod with suffix, isrc variable 2026-04-13 21:53:47 +07:00
afkarxyz 7792a69d33 .add composer and fix multiple value tag 2026-04-13 21:35:55 +07:00
afkarxyz e79622751d .clickable variables 2026-04-13 21:17:07 +07:00
afkarxyz 1b00badd93 .playlist owner folder name 2026-04-13 20:56:24 +07:00
afkarxyz 24d640443a .refine check availibility 2026-04-13 20:43:37 +07:00
afkarxyz 967feb93e1 .fix qobuz api 2026-04-13 20:29:19 +07:00
afkarxyz 475596d934 .readme 2026-04-02 18:38:49 +07:00
afkarxyz 0d42bc3877 .time ago 2026-04-02 18:34:48 +07:00
afkarxyz 7f12b76fd9 .cleanup 2026-04-02 17:41:25 +07:00
afkarxyz 99f5e4e8b3 .reorder layout 2026-04-02 16:28:21 +07:00
afkarxyz 2d2ceac569 .dropdown region songlink 2026-04-02 16:22:14 +07:00
afkarxyz 5fa9da8e23 .refine about page 2026-04-02 16:10:28 +07:00
afkarxyz 0237895603 .refine about page 2026-04-02 15:46:35 +07:00
afkarxyz fc5bda3b26 .url metadata 2026-04-02 15:30:55 +07:00
afkarxyz af72ca0d01 .fix qobuz check 2026-04-02 15:22:23 +07:00
afkarxyz 42278aa1f3 .songlink default 2026-04-02 14:53:46 +07:00
afkarxyz 1128b0245f .deezer url log 2026-04-02 14:48:52 +07:00
afkarxyz 460405a437 .isrc finder fallback 2026-04-02 12:11:56 +07:00
afkarxyz 4b3bf1cf48 .cleanup 2026-04-02 11:45:42 +07:00
afkarxyz 41eda2d230 .batch audio quality analyzer 2026-04-02 11:30:00 +07:00
afkarxyz 78caf6cc61 .simple open folder 2026-04-02 10:36:40 +07:00
afkarxyz 9314b8ec99 .fix audio analyzer alac 2026-04-02 10:31:56 +07:00
afkarxyz cfcb890469 .link resolver 2026-04-02 10:14:49 +07:00
afkarxyz e74ac07afc .rename 2026-04-02 09:34:41 +07:00
afkarxyz 0475529535 .spotfetch isrc 2026-04-02 09:33:12 +07:00
afkarxyz 264b474903 .refine check status 2026-04-02 08:55:24 +07:00
afkarxyz 6066278fe6 .icon 2026-04-02 08:46:34 +07:00
afkarxyz cf36d28444 .separate songlink 2026-04-02 08:36:42 +07:00
afkarxyz 7ce66b4732 .priority api 2026-04-02 08:29:37 +07:00
afkarxyz b96fc8d96c .isrc db 2026-04-02 08:23:58 +07:00
afkarxyz 6de2bae67b .isrc finder 2026-04-02 08:17:35 +07:00
afkarxyz 3e04868746 .update 2026-04-02 08:00:56 +07:00
afkarxyz e3f8f7be0a .cleanup 2026-03-25 20:53:26 +07:00
afkarxyz 5ebd28982b .final 2026-03-25 20:44:31 +07:00
afkarxyz f8ef1180f6 .improve audio quality analyzer 2026-03-25 20:10:05 +07:00
afkarxyz 386c541658 .reorder audio tools 2026-03-25 19:56:23 +07:00
afkarxyz d60a068cab .x 2026-03-25 19:47:40 +07:00
afkarxyz 78adc15be3 .tools refine hover 2026-03-25 19:34:54 +07:00
afkarxyz 724520f51f .refine progressbar audio quality analyzer 2026-03-25 19:25:26 +07:00
afkarxyz c342c3f9ee .remake audio quality analyzer 2026-03-25 18:52:27 +07:00
afkarxyz 8919b9a77a .unicode issue converter 2026-03-25 18:04:22 +07:00
afkarxyz 528bf65771 .refine audio quality analyzer 2026-03-25 17:39:43 +07:00
afkarxyz 0e6b6f9d39 .audio resampler 2026-03-25 17:24:38 +07:00
afkarxyz 45885e1856 .rename 2026-03-25 16:24:24 +07:00
afkarxyz b31e1fe565 .fix spotify rate limit issue 2026-03-25 16:17:44 +07:00
afkarxyz 4e7fc468cd .history page refined 2026-03-25 15:13:36 +07:00
afkarxyz d8722c58dc .refine artist fetch all tracks 2026-03-25 13:43:14 +07:00
afkarxyz dd67b54ea9 .refine issue 2026-03-25 12:06:54 +07:00
afkarxyz cbca6c799f .improve about page 2026-03-25 12:02:14 +07:00
79 changed files with 2481 additions and 6289 deletions
+1 -2
View File
@@ -1,2 +1 @@
ko_fi: afkarxyz
patreon: afkarxyz
ko_fi: afkarxyz
+5 -3
View File
@@ -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.
## Related projects
### [SpotiFLAC (Mobile)](https://github.com/zarzet/SpotiFLAC-Mobile)
SpotiFLAC for Android & iOS — maintained by [@zarzet](https://github.com/zarzet)
### [SpotiFLAC (CLI)](https://github.com/Nizarberyan/SpotiFLAC)
SpotiFLAC for command-line environments — maintained by [@Nizarberyan](https://github.com/Nizarberyan)
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
SpotiFLAC Python library for SpotiFLAC integration — maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu)
@@ -106,7 +108,7 @@ The software is provided "as is", without warranty of any kind. The author assum
## API Credits
[MusicBrainz](https://musicbrainz.org) · [LRCLIB](https://lrclib.net) · [Songlink/Odesli](https://song.link) · [Songstats](https://songstats.com) · [hifi-api](https://github.com/binimum/hifi-api) · [Qobuz-DL](https://github.com/QobuzDL/Qobuz-DL)
[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]
>
+136 -634
View File
@@ -33,41 +33,12 @@ type CurrentIPInfo struct {
Source string `json:"source,omitempty"`
}
type APIStatusTargetResult struct {
Target string `json:"target"`
Label string `json:"label"`
Online bool `json:"online"`
Message string `json:"message,omitempty"`
}
type APIStatusReport struct {
Type string `json:"type"`
Online bool `json:"online"`
RequireAll bool `json:"require_all"`
Details []APIStatusTargetResult `json:"details"`
}
const checkOperationTimeout = 10 * time.Second
func NewApp() *App {
return &App{}
}
func (a *App) LogStatusConsole(level string, message string) {
normalizedLevel := strings.ToLower(strings.TrimSpace(level))
if normalizedLevel == "" {
normalizedLevel = "info"
}
line := fmt.Sprintf("[%s] [%s] %s\n", time.Now().Format("15:04:05"), normalizedLevel, strings.TrimSpace(message))
switch normalizedLevel {
case "error":
_, _ = fmt.Fprint(os.Stderr, line)
default:
fmt.Print(line)
}
}
type timedResult[T any] struct {
value T
err error
@@ -305,12 +276,11 @@ func (a *App) startup(ctx context.Context) {
if err := backend.InitProviderPriorityDB(); err != nil {
fmt.Printf("Failed to init provider priority DB: %v\n", err)
}
if err := backend.CleanupLegacyTidalPublicAPIState(); err != nil {
fmt.Printf("Failed to clean legacy Tidal API cache: %v\n", err)
}
if err := backend.SanitizePersistedConfigSettings(); err != nil {
fmt.Printf("Failed to sanitize persisted config settings: %v\n", err)
}
go func() {
if err := backend.PrimeTidalAPIList(); err != nil {
fmt.Printf("Failed to prime Tidal API list: %v\n", err)
}
}()
}
func (a *App) shutdown(ctx context.Context) {
@@ -337,7 +307,7 @@ type DownloadRequest struct {
ReleaseDate string `json:"release_date,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
TidalAPIURL string `json:"tidal_api_url,omitempty"`
QobuzAPIURL string `json:"qobuz_api_url,omitempty"`
TidalVariant string `json:"tidal_variant,omitempty"`
OutputDir string `json:"output_dir,omitempty"`
AudioFormat string `json:"audio_format,omitempty"`
FilenameFormat string `json:"filename_format,omitempty"`
@@ -373,7 +343,6 @@ type DownloadResponse struct {
File string `json:"file,omitempty"`
Error string `json:"error,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
Cancelled bool `json:"cancelled,omitempty"`
ItemID string `json:"item_id,omitempty"`
}
@@ -539,8 +508,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
if req.FilenameFormat == "" {
req.FilenameFormat = "title-artist"
}
shouldResolveISRC := strings.Contains(req.FilenameFormat, "{isrc}") || backend.GetExistingFileCheckModeSetting() == "isrc"
if req.ISRC == "" && shouldResolveISRC && req.SpotifyID != "" {
if req.ISRC == "" && strings.Contains(req.FilenameFormat, "{isrc}") && req.SpotifyID != "" {
req.ISRC = backend.ResolveTrackISRC(req.SpotifyID)
}
@@ -560,20 +528,6 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
backend.StartDownloadItem(itemID)
defer backend.SetDownloading(false)
_, finishDownloadScope := backend.BeginDownloadCancellationScope()
defer finishDownloadScope()
if err := backend.CheckDownloadCancelled(); err != nil {
backend.SkipDownloadItem(itemID, "")
return DownloadResponse{
Success: false,
Message: "Download cancelled",
Error: "Download cancelled",
ItemID: itemID,
Cancelled: true,
}, nil
}
spotifyURL := ""
if req.SpotifyID != "" {
spotifyURL = fmt.Sprintf("https://open.spotify.com/track/%s", req.SpotifyID)
@@ -708,11 +662,24 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
}
case "tidal":
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)
tidalVariant := strings.ToLower(strings.TrimSpace(req.TidalVariant))
if tidalVariant == "alt" {
downloader := backend.NewTidalDownloader("")
filename, err = downloader.DownloadAlt(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
} else if req.TidalAPIURL == "" || req.TidalAPIURL == "auto" {
downloader := backend.NewTidalDownloader("")
if req.ServiceURL != "" {
filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
} else {
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
}
} 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":
@@ -723,9 +690,6 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
isrc = <-isrcChan
}
downloader := backend.NewQobuzDownloader()
if strings.HasPrefix(strings.TrimRight(strings.TrimSpace(req.QobuzAPIURL), "/"), "https://") {
downloader.SetCustomAPIURL(req.QobuzAPIURL)
}
quality := req.AudioFormat
if quality == "" {
quality = "6"
@@ -740,22 +704,6 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
}
if err != nil {
if backend.IsDownloadCancelledError(err) {
if filename != "" && !strings.HasPrefix(filename, "EXISTS:") {
if _, statErr := os.Stat(filename); statErr == nil {
os.Remove(filename)
}
}
backend.SkipDownloadItem(itemID, "")
return DownloadResponse{
Success: false,
Message: "Download cancelled",
Error: "Download cancelled",
ItemID: itemID,
Cancelled: true,
}, nil
}
backend.FailDownloadItem(itemID, fmt.Sprintf("Download failed: %v", err))
if filename != "" && !strings.HasPrefix(filename, "EXISTS:") {
@@ -781,20 +729,6 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
filename = strings.TrimPrefix(filename, "EXISTS:")
}
if !alreadyExists {
if err := backend.CheckDownloadCancelled(); err != nil {
cleanupInvalidDownloadArtifacts(filename)
backend.SkipDownloadItem(itemID, "")
return DownloadResponse{
Success: false,
Message: "Download cancelled",
Error: "Download cancelled",
ItemID: itemID,
Cancelled: true,
}, nil
}
}
if !alreadyExists {
validated, validationErr := backend.ValidateDownloadedTrackDuration(filename, req.Duration)
if validationErr != nil {
@@ -861,6 +795,9 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
}
historySource := req.Service
if req.Service == "tidal" && strings.EqualFold(strings.TrimSpace(req.TidalVariant), "alt") {
historySource = "tidal alt"
}
go func(fPath, track, artist, album, sID, cover, format, source string) {
time.Sleep(2 * time.Second)
@@ -889,21 +826,21 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
DurationStr: durationStr,
CoverURL: cover,
Quality: quality,
Format: strings.ToUpper(format),
Path: fPath,
Source: source,
}
item.Format = strings.ToUpper(strings.TrimSpace(format))
if ext := filepath.Ext(fPath); len(ext) > 1 {
item.Format = strings.ToUpper(ext[1:])
if item.Format == "" || item.Format == "LOSSLESS" {
ext := filepath.Ext(fPath)
if len(ext) > 1 {
item.Format = strings.ToUpper(ext[1:])
}
}
switch item.Format {
case "6", "7", "27", "LOSSLESS", "HI_RES", "HI_RES_LOSSLESS":
case "6", "7", "27":
item.Format = "FLAC"
case "ALAC", "APPLE", "ATMOS", "M4A-AAC", "M4A-ALAC":
item.Format = "M4A"
}
backend.AddHistoryItem(item, "SpotiFLAC")
@@ -984,10 +921,6 @@ func (a *App) CancelAllQueuedItems() {
backend.CancelAllQueuedItems()
}
func (a *App) ForceStopDownloads() {
backend.ForceStopActiveDownloads()
}
func (a *App) ExportFailedDownloads() (string, error) {
queueInfo := backend.GetDownloadQueue()
var failedItems []string
@@ -1060,7 +993,15 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
isOnline, err := runWithTimeout(checkOperationTimeout, func() (bool, error) {
switch apiType {
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":
return checkGroupedAPIStatus("qobuz", buildQobuzStatusCheckURLs(apiURL)), nil
case "amazon":
@@ -1088,191 +1029,48 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
return isOnline
}
func (a *App) CheckAPIStatusReport(apiType string, apiURL string) APIStatusReport {
report, err := runWithTimeout(checkOperationTimeout, func() (APIStatusReport, error) {
switch apiType {
case "tidal":
return buildGroupedAPIStatusReport("tidal", buildTidalStatusCheckURLs(apiURL), false), nil
case "qobuz", "qbz":
return buildGroupedAPIStatusReport("qobuz", buildQobuzStatusCheckURLs(apiURL), false), nil
case "amazon":
return buildGroupedAPIStatusReport("amazon", buildAmazonStatusCheckURLs(apiURL), false), nil
case "lrclib":
return buildGroupedAPIStatusReport("lrclib", buildLRCLIBStatusCheckURLs(apiURL), false), nil
case "musicbrainz":
return buildGroupedAPIStatusReport("musicbrainz", buildMusicBrainzStatusCheckURLs(apiURL), false), nil
default:
return buildGroupedAPIStatusReport(apiType, []string{strings.TrimSpace(apiURL)}, false), nil
}
})
if err != nil {
return APIStatusReport{
Type: apiType,
Online: false,
RequireAll: apiType == "qobuz" || apiType == "qbz",
Details: []APIStatusTargetResult{{
Target: strings.TrimSpace(apiURL),
Label: describeAPIStatusTarget(apiType, apiURL),
Online: false,
Message: err.Error(),
}},
}
}
return report
}
func (a *App) CheckCustomTidalAPI(apiURL string) bool {
type tidalProbeResponse struct {
Version string `json:"version"`
Data struct {
TrackID int64 `json:"trackId"`
AssetPresentation string `json:"assetPresentation"`
ManifestMimeType string `json:"manifestMimeType"`
Manifest string `json:"manifest"`
} `json:"data"`
}
type tidalLegacyResponse struct {
OriginalTrackURL string `json:"OriginalTrackUrl"`
}
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
if apiURL == "" {
return false
}
const probeTrackID int64 = 441821360
probeURL := fmt.Sprintf("%s/track/?id=%d&quality=LOSSLESS", apiURL, probeTrackID)
req, err := http.NewRequest(http.MethodGet, probeURL, nil)
if err != nil {
fmt.Printf("[CheckCustomTidalAPI] Failed to create request for %s: %v\n", apiURL, err)
return false
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36")
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 12 * time.Second}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("[CheckCustomTidalAPI] Probe request failed for %s: %v\n", apiURL, err)
return false
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
fmt.Printf("[CheckCustomTidalAPI] Failed to read probe response for %s: %v\n", apiURL, err)
return false
}
if resp.StatusCode != http.StatusOK {
fmt.Printf("[CheckCustomTidalAPI] Probe returned status %d for %s: %s\n", resp.StatusCode, apiURL, previewResponseBody(body, 200))
return false
}
var probe tidalProbeResponse
if err := json.Unmarshal(body, &probe); err == nil {
assetPresentation := strings.ToUpper(strings.TrimSpace(probe.Data.AssetPresentation))
switch assetPresentation {
case "FULL":
if strings.TrimSpace(probe.Data.Manifest) != "" {
fmt.Printf("[CheckCustomTidalAPI] Tidal API is ONLINE for %s (assetPresentation=%s)\n", apiURL, assetPresentation)
return true
}
fmt.Printf("[CheckCustomTidalAPI] Probe returned FULL without manifest for %s\n", apiURL)
return false
case "PREVIEW":
fmt.Printf("[CheckCustomTidalAPI] Probe returned PREVIEW for %s\n", apiURL)
return false
case "":
default:
fmt.Printf("[CheckCustomTidalAPI] Probe returned unsupported assetPresentation=%s for %s\n", assetPresentation, apiURL)
return false
}
}
var legacy []tidalLegacyResponse
if err := json.Unmarshal(body, &legacy); err == nil {
for _, item := range legacy {
if strings.TrimSpace(item.OriginalTrackURL) != "" {
fmt.Printf("[CheckCustomTidalAPI] Tidal API is ONLINE for %s (legacy response)\n", apiURL)
return true
}
}
}
fmt.Printf("[CheckCustomTidalAPI] Probe response was unusable for %s: %s\n", apiURL, previewResponseBody(body, 200))
return false
}
func (a *App) CheckCustomQobuzAPI(apiURL string) bool {
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
if !strings.HasPrefix(apiURL, "https://") {
return false
}
const probeTrackID int64 = 64868955
probeURL := fmt.Sprintf("%s/api/download-music?track_id=%d&quality=27", apiURL, probeTrackID)
req, err := http.NewRequest(http.MethodGet, probeURL, nil)
if err != nil {
fmt.Printf("[CheckCustomQobuzAPI] 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: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("[CheckCustomQobuzAPI] 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("[CheckCustomQobuzAPI] Failed to read probe response for %s: %v\n", apiURL, err)
return false
}
if resp.StatusCode != http.StatusOK {
fmt.Printf("[CheckCustomQobuzAPI] Probe returned status %d for %s: %s\n", resp.StatusCode, apiURL, previewResponseBody(body, 200))
return false
}
var probe struct {
Success bool `json:"success"`
Data struct {
URL string `json:"url"`
} `json:"data"`
}
if err := json.Unmarshal(body, &probe); err != nil {
fmt.Printf("[CheckCustomQobuzAPI] Failed to decode probe response for %s: %v\n", apiURL, err)
return false
}
if probe.Success && strings.TrimSpace(probe.Data.URL) != "" {
fmt.Printf("[CheckCustomQobuzAPI] Qobuz instance is ONLINE for %s\n", apiURL)
return true
}
fmt.Printf("[CheckCustomQobuzAPI] Probe response was unusable for %s: %s\n", apiURL, previewResponseBody(body, 200))
return false
}
func buildTidalStatusCheckURLs(apiURL string) []string {
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
if apiURL == "" {
return nil
if apiURL != "" {
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 {
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 {
@@ -1338,185 +1136,8 @@ func checkGroupedAPIStatus(apiType string, checkURLs []string) bool {
return false
}
func buildGroupedAPIStatusReport(apiType string, checkURLs []string, requireAll bool) APIStatusReport {
filtered := make([]string, 0, len(checkURLs))
for _, rawURL := range checkURLs {
target := strings.TrimSpace(rawURL)
if target == "" {
continue
}
filtered = append(filtered, target)
}
report := APIStatusReport{
Type: apiType,
Online: !requireAll,
RequireAll: requireAll,
Details: make([]APIStatusTargetResult, len(filtered)),
}
if len(filtered) == 0 {
report.Online = false
return report
}
var wg sync.WaitGroup
for index, target := range filtered {
wg.Add(1)
go func(idx int, rawTarget string) {
defer wg.Done()
report.Details[idx] = checkSingleAPIStatusDetailed(apiType, rawTarget)
}(index, target)
}
wg.Wait()
if requireAll {
report.Online = true
for _, detail := range report.Details {
if !detail.Online {
report.Online = false
break
}
}
} else {
report.Online = false
for _, detail := range report.Details {
if detail.Online {
report.Online = true
break
}
}
}
return report
}
func describeAPIStatusTarget(apiType string, checkURL string) string {
trimmedType := strings.TrimSpace(strings.ToLower(apiType))
trimmedURL := strings.TrimSpace(checkURL)
if trimmedType == "qobuz" || trimmedType == "qbz" {
switch {
case backend.IsQobuzWJHEProviderURL(trimmedURL):
return "WJHE"
case backend.IsQobuzMusicDLProviderURL(trimmedURL):
return "MusicDL"
case backend.IsQobuzGDStudioProviderURL(trimmedURL):
parsed, err := url.Parse(trimmedURL)
if err == nil {
host := strings.ToLower(strings.TrimSpace(parsed.Host))
switch {
case strings.Contains(host, "xyz"):
return "GDStudio XYZ"
case strings.Contains(host, "org"):
return "GDStudio ORG"
}
}
return "GDStudio"
}
}
if trimmedURL != "" {
if parsed, err := url.Parse(trimmedURL); err == nil && strings.TrimSpace(parsed.Host) != "" {
return strings.TrimSpace(parsed.Host)
}
}
if trimmedType == "" {
return "Unknown"
}
return strings.ToUpper(trimmedType)
}
func checkSingleAPIStatusDetailed(apiType string, checkURL string) APIStatusTargetResult {
result := APIStatusTargetResult{
Target: strings.TrimSpace(checkURL),
Label: describeAPIStatusTarget(apiType, checkURL),
}
client := &http.Client{Timeout: 4 * time.Second}
trimmedType := strings.TrimSpace(strings.ToLower(apiType))
if trimmedType == "qobuz" || trimmedType == "qbz" {
var err error
switch {
case backend.IsQobuzWJHEProviderURL(checkURL):
err = backend.CheckQobuzWJHEStatusDetailed(client)
case backend.IsQobuzMusicDLProviderURL(checkURL):
err = backend.CheckQobuzMusicDLStatusDetailed(client)
case backend.IsQobuzGDStudioProviderURL(checkURL):
err = backend.CheckQobuzGDStudioAPIStatusDetailed(client, checkURL)
default:
err = fmt.Errorf("unknown qobuz provider url: %s", strings.TrimSpace(checkURL))
}
if err != nil {
result.Message = err.Error()
return result
}
result.Online = true
result.Message = "stream URL resolved"
return result
}
req, err := backend.NewRequestWithDefaultHeaders(http.MethodGet, checkURL, nil)
if err != nil {
result.Message = fmt.Sprintf("failed to create request: %v", err)
return result
}
resp, err := client.Do(req)
if err != nil {
result.Message = fmt.Sprintf("request failed: %v", err)
return result
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 2048))
if err != nil {
result.Message = fmt.Sprintf("failed to read response: %v", err)
return result
}
switch trimmedType {
case "amazon":
if resp.StatusCode == http.StatusOK && strings.Contains(string(body), `"amazonMusic":"up"`) {
result.Online = true
result.Message = `amazonMusic="up"`
return result
}
if resp.StatusCode != http.StatusOK {
result.Message = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, previewResponseBody(body, 160))
return result
}
result.Message = `amazonMusic was not reported as "up"`
return result
default:
if resp.StatusCode == http.StatusOK {
result.Online = true
result.Message = fmt.Sprintf("HTTP %d", resp.StatusCode)
return result
}
result.Message = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, previewResponseBody(body, 160))
return result
}
}
func checkSingleAPIStatus(apiType string, checkURL string) bool {
client := &http.Client{Timeout: 4 * time.Second}
if apiType == "qobuz" || apiType == "qbz" {
switch {
case backend.IsQobuzWJHEProviderURL(checkURL):
return backend.CheckQobuzWJHEStatus(client)
case backend.IsQobuzMusicDLProviderURL(checkURL):
return backend.CheckQobuzMusicDLStatus(client)
case backend.IsQobuzGDStudioProviderURL(checkURL):
return backend.CheckQobuzGDStudioAPIStatus(client, checkURL)
}
}
req, err := backend.NewRequestWithDefaultHeaders(http.MethodGet, checkURL, nil)
if err != nil {
return false
@@ -2017,28 +1638,6 @@ func (a *App) ReadFileMetadata(filePath string) (*backend.AudioMetadata, error)
return backend.ReadAudioMetadata(filePath)
}
func (a *App) ReadEmbeddedLyrics(filePath string) (*backend.EmbeddedLyrics, error) {
if filePath == "" {
return nil, fmt.Errorf("file path is required")
}
return backend.ReadEmbeddedLyrics(filePath)
}
func (a *App) ExtractLyricsToLRC(filePath string, overwrite bool) (*backend.ExtractLyricsResult, error) {
if filePath == "" {
return nil, fmt.Errorf("file path is required")
}
return backend.ExtractLyricsToLRC(filePath, overwrite)
}
func (a *App) SelectLyricsFiles() ([]string, error) {
files, err := backend.SelectLyricsFiles(a.ctx)
if err != nil {
return nil, err
}
return files, nil
}
func (a *App) PreviewRenameFiles(files []string, format string) []backend.RenamePreview {
return backend.PreviewRename(files, format)
}
@@ -2134,68 +1733,6 @@ type CheckFileExistenceResult struct {
ArtistName string `json:"artist_name,omitempty"`
}
type existingFileLookupIndex struct {
byFilename map[string]string
byISRC map[string]string
}
func isAudioFileForExistenceCheck(path string) bool {
switch strings.ToLower(filepath.Ext(path)) {
case ".flac", ".mp3", ".m4a":
return true
default:
return false
}
}
func normalizeExistingFileIdentifier(value string) string {
return strings.ToUpper(strings.TrimSpace(value))
}
func buildExistingFileLookupIndex(scanRoot string, mode string) existingFileLookupIndex {
index := existingFileLookupIndex{
byFilename: make(map[string]string),
byISRC: make(map[string]string),
}
scanRoot = backend.NormalizePath(scanRoot)
if scanRoot == "" {
return index
}
_ = filepath.Walk(scanRoot, func(path string, info os.FileInfo, err error) error {
if err != nil || info == nil || info.IsDir() || !isAudioFileForExistenceCheck(path) {
return nil
}
if info.Size() <= 100*1024 {
return nil
}
if _, exists := index.byFilename[info.Name()]; !exists {
index.byFilename[info.Name()] = path
}
if mode == "filename" {
return nil
}
metadata, metadataErr := backend.ExtractFullMetadataFromFile(path)
if metadataErr != nil {
return nil
}
if normalizedISRC := normalizeExistingFileIdentifier(metadata.ISRC); normalizedISRC != "" {
if _, exists := index.byISRC[normalizedISRC]; !exists {
index.byISRC[normalizedISRC] = path
}
}
return nil
})
return index
}
func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []CheckFileExistenceRequest) []CheckFileExistenceResult {
if len(tracks) == 0 {
return []CheckFileExistenceResult{}
@@ -2208,11 +1745,6 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
defaultFilenameFormat := "title-artist"
redownloadWithSuffix := backend.GetRedownloadWithSuffixSetting()
existingFileCheckMode := backend.GetExistingFileCheckModeSetting()
scanRoot := outputDir
if rootDir != "" {
scanRoot = rootDir
}
type result struct {
index int
@@ -2220,13 +1752,29 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
}
resultsChan := make(chan result, len(tracks))
var lookupIndex existingFileLookupIndex
var lookupIndexOnce sync.Once
getLookupIndex := func() existingFileLookupIndex {
lookupIndexOnce.Do(func() {
lookupIndex = buildExistingFileLookupIndex(scanRoot, existingFileCheckMode)
})
return lookupIndex
var rootDirFiles map[string]string
rootDirFilesOnce := false
getRootDirFiles := func() map[string]string {
if rootDirFilesOnce {
return rootDirFiles
}
rootDirFiles = make(map[string]string)
if rootDir != "" && rootDir != outputDir {
filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if !info.IsDir() {
if strings.EqualFold(filepath.Ext(path), ".flac") || strings.EqualFold(filepath.Ext(path), ".mp3") {
rootDirFiles[info.Name()] = path
}
}
return nil
})
}
rootDirFilesOnce = true
return rootDirFiles
}
for i, track := range tracks {
@@ -2248,8 +1796,7 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
filenameFormat = defaultFilenameFormat
}
isrc := strings.TrimSpace(t.ISRC)
shouldResolveISRC := existingFileCheckMode == "isrc" || strings.Contains(filenameFormat, "{isrc}")
if isrc == "" && shouldResolveISRC && t.SpotifyID != "" {
if isrc == "" && strings.Contains(filenameFormat, "{isrc}") && t.SpotifyID != "" {
isrc = backend.ResolveTrackISRC(t.SpotifyID)
}
@@ -2259,11 +1806,8 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
}
fileExt := ".flac"
switch strings.ToLower(strings.TrimSpace(t.AudioFormat)) {
case "mp3":
if t.AudioFormat == "mp3" {
fileExt = ".mp3"
case "m4a", "m4a-aac", "m4a-alac", "alac", "atmos", "apple":
fileExt = ".m4a"
}
expectedFilenameBase := backend.BuildExpectedFilename(
@@ -2292,29 +1836,14 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
expectedPath := filepath.Join(targetDir, expectedFilename)
if redownloadWithSuffix {
expectedPath, _ = backend.ResolveOutputPathForDownload(expectedPath, true)
resultsChan <- result{index: idx, result: res}
return
}
normalizedISRC := normalizeExistingFileIdentifier(isrc)
effectiveMode := existingFileCheckMode
if effectiveMode == "isrc" && normalizedISRC == "" {
effectiveMode = "filename"
}
switch effectiveMode {
case "isrc":
if path, ok := getLookupIndex().byISRC[normalizedISRC]; ok {
res.Exists = true
res.FilePath = path
}
default:
res.FilePath = filepath.Base(expectedPath)
} else {
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
res.Exists = true
res.FilePath = expectedPath
} else if path, ok := getLookupIndex().byFilename[filepath.Base(expectedPath)]; ok {
res.Exists = true
res.FilePath = path
} else {
res.FilePath = expectedFilename
}
}
@@ -2323,10 +1852,39 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
}
results := make([]CheckFileExistenceResult, len(tracks))
missingIndices := []int{}
for i := 0; i < len(tracks); i++ {
r := <-resultsChan
results[r.index] = r.result
if !results[r.index].Exists {
missingIndices = append(missingIndices, r.index)
}
}
if len(missingIndices) > 0 && rootDir != "" {
filesMap := getRootDirFiles()
if len(filesMap) > 0 {
for _, idx := range missingIndices {
expectedFilename := results[idx].FilePath
baseName := filepath.Base(expectedFilename)
if path, ok := filesMap[baseName]; ok {
results[idx].Exists = true
results[idx].FilePath = path
} else {
results[idx].FilePath = ""
}
}
} else {
for _, idx := range missingIndices {
results[idx].FilePath = ""
}
}
} else {
for _, idx := range missingIndices {
results[idx].FilePath = ""
}
}
return results
@@ -2352,20 +1910,11 @@ func (a *App) GetConfigPath() (string, error) {
return filepath.Join(dir, "config.json"), nil
}
func (a *App) GetFontsPath() (string, error) {
dir, err := backend.GetFFmpegDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "fonts.json"), nil
}
func (a *App) SaveSettings(settings map[string]interface{}) error {
configPath, err := a.GetConfigPath()
if err != nil {
return err
}
settings = backend.SanitizeSettingsMap(settings)
dir := filepath.Dir(configPath)
if _, err := os.Stat(dir); os.IsNotExist(err) {
@@ -2382,27 +1931,6 @@ func (a *App) SaveSettings(settings map[string]interface{}) error {
return os.WriteFile(configPath, data, 0644)
}
func (a *App) SaveFonts(fonts []map[string]interface{}) error {
fontsPath, err := a.GetFontsPath()
if err != nil {
return err
}
dir := filepath.Dir(fontsPath)
if _, err := os.Stat(dir); os.IsNotExist(err) {
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
}
data, err := json.MarshalIndent(fonts, "", " ")
if err != nil {
return err
}
return os.WriteFile(fontsPath, data, 0644)
}
func (a *App) LoadSettings() (map[string]interface{}, error) {
configPath, err := a.GetConfigPath()
if err != nil {
@@ -2423,33 +1951,7 @@ func (a *App) LoadSettings() (map[string]interface{}, error) {
return nil, err
}
return backend.SanitizeSettingsMap(settings), nil
}
func (a *App) LoadFonts() ([]map[string]interface{}, error) {
fontsPath, err := a.GetFontsPath()
if err != nil {
return nil, err
}
if _, err := os.Stat(fontsPath); os.IsNotExist(err) {
return nil, nil
}
data, err := os.ReadFile(fontsPath)
if err != nil {
return nil, err
}
var fonts []map[string]interface{}
if err := json.Unmarshal(data, &fonts); err != nil {
return nil, err
}
if fonts == nil {
return []map[string]interface{}{}, nil
}
return fonts, nil
return settings, nil
}
func (a *App) CheckFFmpegInstalled() (bool, error) {
+100 -130
View File
@@ -1,7 +1,6 @@
package backend
import (
"bytes"
"encoding/json"
"fmt"
"io"
@@ -19,6 +18,11 @@ type AmazonDownloader struct {
regions []string
}
type AmazonStreamResponse struct {
StreamURL string `json:"streamUrl"`
DecryptionKey string `json:"decryptionKey"`
}
func NewAmazonDownloader() *AmazonDownloader {
return &AmazonDownloader{
client: &http.Client{
@@ -44,29 +48,7 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
return amazonURL, nil
}
type amazonCommunityResponse struct {
ASIN string `json:"asin"`
Codec string `json:"codec"`
BitDepth int `json:"bit_depth"`
URL string `json:"url"`
StreamURL string `json:"stream_url"`
Key string `json:"key"`
KeySpecs []string `json:"key_specs"`
Captcha string `json:"captcha"`
}
func amazonCommunityNormalizeQuality(quality string) string {
switch strings.ToLower(strings.TrimSpace(quality)) {
case "16", "lossless", "cd":
return "16"
case "atmos", "eac3", "dolby":
return "atmos"
default:
return "24"
}
}
func (a *AmazonDownloader) downloadFromCommunity(amazonURL, outputDir, quality string) (string, error) {
func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality string) (string, error) {
asinRegex := regexp.MustCompile(`(B[0-9A-Z]{9})`)
asin := asinRegex.FindString(amazonURL)
@@ -74,28 +56,14 @@ func (a *AmazonDownloader) downloadFromCommunity(amazonURL, outputDir, quality s
return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL)
}
payload, err := json.Marshal(map[string]string{
"id": asin,
"quality": amazonCommunityNormalizeQuality(quality),
"country": "US",
})
apiURL := fmt.Sprintf("%s/api/track/%s", amazonMusicAPIBaseURL, asin)
req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
if err != nil {
return "", err
}
fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin)
resp, err := doCommunityRequest(a.client, "Amazon", func() (*http.Request, error) {
req, err := NewRequestWithDefaultHeaders(http.MethodPost, GetAmazonCommunityDownloadURL(), bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if err := setCommunityRequestHeaders(req); err != nil {
return nil, err
}
return req, nil
})
resp, err := a.client.Do(req)
if err != nil {
return "", err
}
@@ -110,43 +78,29 @@ func (a *AmazonDownloader) downloadFromCommunity(amazonURL, outputDir, quality s
return "", err
}
var apiResp amazonCommunityResponse
var apiResp AmazonStreamResponse
if err := json.Unmarshal(bodyBytes, &apiResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
streamURL := strings.TrimSpace(apiResp.StreamURL)
if streamURL == "" {
streamURL = strings.TrimSpace(apiResp.URL)
}
if streamURL == "" {
if apiResp.StreamURL == "" {
return "", fmt.Errorf("no stream URL found in response")
}
keySpecs := apiResp.KeySpecs
if len(keySpecs) == 0 {
if key := strings.TrimSpace(apiResp.Key); key != "" {
keySpecs = []string{key}
}
}
downloadURL := apiResp.StreamURL
fileName := fmt.Sprintf("%s.m4a", asin)
filePath := filepath.Join(outputDir, fileName)
encryptedPath := filepath.Join(outputDir, fmt.Sprintf("%s.encrypted.mp4", asin))
out, err := os.Create(encryptedPath)
out, err := os.Create(filePath)
if err != nil {
return "", err
}
defer func() {
out.Close()
os.Remove(encryptedPath)
}()
defer out.Close()
dlReq, err := NewRequestWithDefaultHeaders(http.MethodGet, streamURL, nil)
dlReq, err := NewRequestWithDefaultHeaders(http.MethodGet, downloadURL, nil)
if err != nil {
return "", err
}
if captcha := strings.TrimSpace(apiResp.Captcha); captcha != "" {
dlReq.Header.Set("x-captcha-token", captcha)
}
dlResp, err := a.client.Do(dlReq)
if err != nil {
@@ -154,85 +108,101 @@ func (a *AmazonDownloader) downloadFromCommunity(amazonURL, outputDir, quality s
}
defer dlResp.Body.Close()
fmt.Printf("Downloading track: %s\n", asin)
fmt.Printf("Downloading track: %s\n", fileName)
pw := NewProgressWriter(out)
if _, err = io.Copy(pw, dlResp.Body); err != nil {
_, err = io.Copy(pw, dlResp.Body)
if err != nil {
out.Close()
os.Remove(filePath)
return "", err
}
out.Close()
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
remuxInput := encryptedPath
if len(keySpecs) > 0 {
if apiResp.DecryptionKey != "" {
fmt.Printf("Decrypting file...\n")
decryptedPath := filepath.Join(outputDir, fmt.Sprintf("%s.decrypted.mp4", asin))
if err := decryptWithMP4FF(keySpecs, encryptedPath, decryptedPath); err != nil {
return "", err
ffprobePath, err := GetFFprobePath()
var codec string
if err == nil {
cmdProbe := exec.Command(ffprobePath,
"-v", "quiet",
"-select_streams", "a:0",
"-show_entries", "stream=codec_name",
"-of", "default=noprint_wrappers=1:nokey=1",
filePath,
)
setHideWindow(cmdProbe)
codecOutput, _ := cmdProbe.Output()
codec = strings.TrimSpace(string(codecOutput))
fmt.Printf("Detected codec: %s\n", codec)
}
defer os.Remove(decryptedPath)
remuxInput = decryptedPath
targetExt := ".m4a"
if codec == "flac" {
targetExt = ".flac"
}
decryptedFilename := "dec_" + fileName + targetExt
if targetExt == ".flac" && strings.HasSuffix(fileName, ".m4a") {
decryptedFilename = "dec_" + strings.TrimSuffix(fileName, ".m4a") + ".flac"
}
decryptedPath := filepath.Join(outputDir, decryptedFilename)
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return "", fmt.Errorf("ffmpeg not found for decryption: %w", err)
}
if err := ValidateExecutable(ffmpegPath); err != nil {
return "", fmt.Errorf("invalid ffmpeg executable: %w", err)
}
key := strings.TrimSpace(apiResp.DecryptionKey)
cmd := exec.Command(ffmpegPath,
"-decryption_key", key,
"-i", filePath,
"-c", "copy",
"-y",
decryptedPath,
)
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
if err != nil {
outStr := string(output)
if len(outStr) > 500 {
outStr = outStr[len(outStr)-500:]
}
return "", fmt.Errorf("ffmpeg decryption failed: %v\nTail Output: %s", err, outStr)
}
if info, err := os.Stat(decryptedPath); err != nil || info.Size() == 0 {
return "", fmt.Errorf("decrypted file missing or empty")
}
if err := os.Remove(filePath); err != nil {
fmt.Printf("Warning: Failed to remove encrypted file: %v\n", err)
}
finalPath := filepath.Join(outputDir, strings.TrimPrefix(decryptedFilename, "dec_"))
if err := os.Rename(decryptedPath, finalPath); err != nil {
return "", fmt.Errorf("failed to rename decrypted file: %w", err)
}
filePath = finalPath
fmt.Println("Decryption successful")
}
targetExt := ".flac"
if codec := strings.ToLower(strings.TrimSpace(apiResp.Codec)); codec == "eac3" || codec == "ec-3" || codec == "ac-3" {
targetExt = ".m4a"
}
finalPath := filepath.Join(outputDir, asin+targetExt)
if err := amazonRemuxWithFFmpeg(remuxInput, finalPath, targetExt); err != nil {
return "", err
}
if info, err := os.Stat(finalPath); err != nil || info.Size() == 0 {
return "", fmt.Errorf("remuxed file missing or empty")
}
return finalPath, nil
}
func amazonRemuxWithFFmpeg(inputPath, outputPath, targetExt string) error {
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return fmt.Errorf("ffmpeg not found for remux: %w", err)
}
if err := ValidateExecutable(ffmpegPath); err != nil {
return fmt.Errorf("invalid ffmpeg executable: %w", err)
}
runFFmpeg := func(args ...string) (string, error) {
cmd := exec.Command(ffmpegPath, args...)
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
return string(output), err
}
args := []string{"-y", "-i", inputPath, "-map", "0:a:0", "-vn", "-c:a", "copy"}
if targetExt == ".m4a" {
args = append(args, "-f", "mp4")
}
args = append(args, outputPath)
if output, err := runFFmpeg(args...); err != nil {
if targetExt == ".flac" {
if output2, err2 := runFFmpeg("-y", "-i", inputPath, "-map", "0:a:0", "-vn", "-c:a", "flac", outputPath); err2 == nil {
return nil
} else {
output = output2
err = err2
}
}
if len(output) > 500 {
output = output[len(output)-500:]
}
return fmt.Errorf("ffmpeg remux failed: %v\nTail Output: %s", err, output)
}
return nil
return filePath, nil
}
func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality string) (string, error) {
return a.downloadFromCommunity(amazonURL, outputDir, quality)
return a.DownloadFromAfkarXYZ(amazonURL, outputDir, quality)
}
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
@@ -289,7 +259,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
fmt.Println("Fetching MusicBrainz metadata...")
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
res.Metadata = fetchedMeta
fmt.Println("MusicBrainz metadata fetched")
fmt.Println("MusicBrainz metadata fetched")
} else {
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
}
@@ -470,7 +440,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
}
fmt.Println("Done")
fmt.Println("Downloaded successfully from Amazon Music")
fmt.Println("Downloaded successfully from Amazon Music")
return filePath, nil
}
-97
View File
@@ -1,97 +0,0 @@
package backend
import (
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"fmt"
"net/http"
"strings"
"sync"
)
var (
communityAPIKeyOnce sync.Once
communityAPIKey string
communityAPIKeyErr error
)
var communityAPIKeySeedParts = [][]byte{
[]byte("spotif"),
[]byte("lac:co"),
[]byte("mmunity:apikey:v1"),
}
var communityAPIKeyAAD = []byte("spotiflac|community|apikey|v1")
var communityAPIKeyNonce = []byte{
0x20, 0x5c, 0x92, 0x4b, 0x61, 0xc2, 0x79, 0xd3, 0xea, 0x5d, 0xdd, 0xd4,
}
var communityAPIKeyCiphertext = []byte{
0x51, 0x0b, 0x26, 0xaf, 0xac, 0x6f, 0xf6, 0x41, 0x79, 0xde, 0x8d, 0x36,
0x83, 0x46, 0xb5, 0xd5, 0x96, 0xef, 0xad, 0xed, 0xe0, 0xd0, 0xc7, 0xc2,
0x90, 0x01, 0x50, 0x5f, 0x55, 0x59, 0x9f, 0xac, 0x1f, 0xd0, 0x70, 0x18,
0x91, 0x4f, 0x7a, 0x32,
}
var communityAPIKeyTag = []byte{
0x56, 0xb0, 0x28, 0x68, 0x9f, 0x39, 0x0d, 0xbc, 0xc0, 0x8e, 0xfb, 0x52,
0x3a, 0xd6, 0x18, 0xae,
}
func getCommunityAPIKey() (string, error) {
communityAPIKeyOnce.Do(func() {
hasher := sha256.New()
for _, part := range communityAPIKeySeedParts {
hasher.Write(part)
}
block, err := aes.NewCipher(hasher.Sum(nil))
if err != nil {
communityAPIKeyErr = err
return
}
gcm, err := cipher.NewGCM(block)
if err != nil {
communityAPIKeyErr = err
return
}
sealed := make([]byte, 0, len(communityAPIKeyCiphertext)+len(communityAPIKeyTag))
sealed = append(sealed, communityAPIKeyCiphertext...)
sealed = append(sealed, communityAPIKeyTag...)
plaintext, err := gcm.Open(nil, communityAPIKeyNonce, sealed, communityAPIKeyAAD)
if err != nil {
communityAPIKeyErr = err
return
}
communityAPIKey = string(plaintext)
})
if communityAPIKeyErr != nil {
return "", communityAPIKeyErr
}
return communityAPIKey, nil
}
func communityUserAgent() string {
version := strings.TrimSpace(AppVersion)
if version == "" || version == "Unknown" {
return "SpotiFLAC"
}
return "SpotiFLAC/" + version
}
func setCommunityRequestHeaders(req *http.Request) error {
apiKey, err := getCommunityAPIKey()
if err != nil {
return fmt.Errorf("failed to prepare community API key: %w", err)
}
req.Header.Set("x-api-key", apiKey)
req.Header.Set("User-Agent", communityUserAgent())
return nil
}
-180
View File
@@ -1,180 +0,0 @@
package backend
import (
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
const communityDownloadPath = "/api/dl"
var communityURLSeedParts = [][]byte{
[]byte("spotif"),
[]byte("lac:co"),
[]byte("mmunity:url:v1"),
}
var communityURLAAD = []byte("spotiflac|community|url|v1")
var (
tidalCommunityURLNonce = []byte{
0x6a, 0x2a, 0x9e, 0xf3, 0x25, 0x5f, 0x48, 0x3c, 0xc3, 0xdf, 0x1d, 0xa9,
}
tidalCommunityURLCiphertext = []byte{
0x8f, 0x90, 0xa4, 0x28, 0x24, 0x06, 0x35, 0x13, 0x2d, 0x33, 0x96, 0x9a,
0xd7, 0x2c, 0x31, 0x42, 0x6a, 0xf3, 0xee, 0x86, 0x34, 0x99, 0x15, 0x1e,
0xa9, 0x07, 0x06, 0xe6, 0xee, 0x0d, 0x75,
}
tidalCommunityURLTag = []byte{
0x4d, 0x1c, 0x4e, 0x98, 0x96, 0x07, 0x16, 0xad, 0x6a, 0x7c, 0xa0, 0xdf,
0xe9, 0xc5, 0xf6, 0x87,
}
qobuzCommunityURLNonce = []byte{
0x5f, 0xd8, 0xfd, 0xfd, 0x89, 0x83, 0xe7, 0x6c, 0xde, 0x48, 0x47, 0x8d,
}
qobuzCommunityURLCiphertext = []byte{
0xfa, 0x35, 0x21, 0xba, 0x02, 0xc6, 0x15, 0x1f, 0x0e, 0xa3, 0xa6, 0x16,
0x64, 0x2b, 0xd8, 0xfb, 0xf5, 0x35, 0xfe, 0xe9, 0x0e, 0x59, 0xd9, 0x25,
0x72, 0x57, 0x88, 0x94, 0xa9, 0xb7, 0x70,
}
qobuzCommunityURLTag = []byte{
0xd7, 0x72, 0xb5, 0x2b, 0x1c, 0xb1, 0xfd, 0xba, 0x22, 0x09, 0x25, 0x41,
0x87, 0x85, 0x30, 0x1b,
}
amazonCommunityURLNonce = []byte{
0x55, 0x18, 0x01, 0x42, 0x42, 0x0c, 0xf6, 0x78, 0x8a, 0x73, 0xd7, 0x63,
}
amazonCommunityURLCiphertext = []byte{
0xd2, 0xf3, 0xdc, 0xe8, 0x62, 0xf0, 0xad, 0xc2, 0x4a, 0x43, 0xb1, 0xa2,
0x1c, 0x0d, 0x41, 0x3e, 0x2e, 0x30, 0x29, 0x5e, 0x46, 0xe2, 0xc2, 0xd6,
0xc1, 0xf3, 0xe3, 0x1a, 0x8f, 0x67, 0xfe,
}
amazonCommunityURLTag = []byte{
0xf9, 0x0a, 0xfd, 0xed, 0x9e, 0xe8, 0xb4, 0xc0, 0x75, 0xf3, 0xd5, 0x74,
0x3c, 0xb6, 0xa1, 0xb9,
}
)
var (
communityURLGCMOnce sync.Once
communityURLGCM cipher.AEAD
communityURLGCMErr error
)
func communityURLCipher() (cipher.AEAD, error) {
communityURLGCMOnce.Do(func() {
hasher := sha256.New()
for _, part := range communityURLSeedParts {
hasher.Write(part)
}
block, err := aes.NewCipher(hasher.Sum(nil))
if err != nil {
communityURLGCMErr = err
return
}
gcm, err := cipher.NewGCM(block)
if err != nil {
communityURLGCMErr = err
return
}
communityURLGCM = gcm
})
return communityURLGCM, communityURLGCMErr
}
func decryptCommunityURL(nonce, ciphertext, tag []byte) (string, error) {
gcm, err := communityURLCipher()
if err != nil {
return "", err
}
sealed := make([]byte, 0, len(ciphertext)+len(tag))
sealed = append(sealed, ciphertext...)
sealed = append(sealed, tag...)
plaintext, err := gcm.Open(nil, nonce, sealed, communityURLAAD)
if err != nil {
return "", err
}
return string(plaintext), nil
}
const communityRateLimitMaxRetries = 6
const communityRateLimitFallbackWait = 30 * time.Second
func GetTidalCommunityDownloadURL() string {
base, _ := decryptCommunityURL(tidalCommunityURLNonce, tidalCommunityURLCiphertext, tidalCommunityURLTag)
return base + communityDownloadPath
}
func GetQobuzCommunityDownloadURL() string {
base, _ := decryptCommunityURL(qobuzCommunityURLNonce, qobuzCommunityURLCiphertext, qobuzCommunityURLTag)
return base + communityDownloadPath
}
func GetAmazonCommunityDownloadURL() string {
base, _ := decryptCommunityURL(amazonCommunityURLNonce, amazonCommunityURLCiphertext, amazonCommunityURLTag)
return base + communityDownloadPath
}
func communityRetryAfter(resp *http.Response) time.Duration {
if resp == nil {
return communityRateLimitFallbackWait
}
if ra := strings.TrimSpace(resp.Header.Get("Retry-After")); ra != "" {
if secs, err := strconv.Atoi(ra); err == nil && secs >= 0 {
return time.Duration(secs)*time.Second + 250*time.Millisecond
}
}
if reset := strings.TrimSpace(resp.Header.Get("X-RateLimit-Reset")); reset != "" {
if epoch, err := strconv.ParseInt(reset, 10, 64); err == nil {
if wait := time.Until(time.Unix(epoch, 0)); wait > 0 {
return wait + 250*time.Millisecond
}
}
}
return communityRateLimitFallbackWait
}
func doCommunityRequest(client *http.Client, service string, reqFn func() (*http.Request, error)) (*http.Response, error) {
var lastErr error
for attempt := 0; attempt <= communityRateLimitMaxRetries; attempt++ {
req, err := reqFn()
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusTooManyRequests {
ClearRateLimitCooldown()
return resp, nil
}
wait := communityRetryAfter(resp)
resp.Body.Close()
lastErr = fmt.Errorf("%s community API rate limited (429)", service)
if attempt == communityRateLimitMaxRetries {
break
}
fmt.Printf("%s rate limited, waiting %.0fs before retry (%d/%d)...\n", service, wait.Seconds(), attempt+1, communityRateLimitMaxRetries)
SetRateLimitCooldown(wait.Seconds())
if sleepErr := SleepWithDownloadContext(wait); sleepErr != nil {
ClearRateLimitCooldown()
return nil, sleepErr
}
ClearRateLimitCooldown()
}
return nil, lastErr
}
+1 -156
View File
@@ -2,138 +2,11 @@ package backend
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
)
const legacyTidalAPICacheFile = "tidal-api-urls.json"
func normalizeCustomTidalAPIValue(value interface{}) string {
customAPI, _ := value.(string)
customAPI = strings.TrimRight(strings.TrimSpace(customAPI), "/")
if strings.HasPrefix(customAPI, "https://") {
return customAPI
}
return ""
}
func sanitizeDownloaderValue(value interface{}, allowTidal bool) string {
downloader, _ := value.(string)
switch strings.TrimSpace(strings.ToLower(downloader)) {
case "tidal":
if allowTidal {
return "tidal"
}
return "auto"
case "qobuz":
return "qobuz"
case "amazon":
return "amazon"
default:
return "auto"
}
}
func sanitizeAutoOrderValue(value interface{}, allowTidal bool) string {
autoOrder, _ := value.(string)
allowed := map[string]struct{}{
"qobuz": {},
"amazon": {},
}
fallback := "qobuz-amazon"
if allowTidal {
allowed["tidal"] = struct{}{}
fallback = "tidal-qobuz-amazon"
}
seen := make(map[string]struct{})
parts := make([]string, 0, 3)
for _, rawPart := range strings.Split(strings.TrimSpace(strings.ToLower(autoOrder)), "-") {
part := strings.TrimSpace(rawPart)
if part == "" {
continue
}
if _, ok := allowed[part]; !ok {
continue
}
if _, ok := seen[part]; ok {
continue
}
seen[part] = struct{}{}
parts = append(parts, part)
}
if len(parts) < 2 {
return fallback
}
return strings.Join(parts, "-")
}
func SanitizeSettingsMap(settings map[string]interface{}) map[string]interface{} {
if settings == nil {
return nil
}
sanitized := make(map[string]interface{}, len(settings))
for key, value := range settings {
sanitized[key] = value
}
customAPI := normalizeCustomTidalAPIValue(sanitized["customTidalApi"])
sanitized["customTidalApi"] = customAPI
allowTidal := customAPI != ""
sanitized["downloader"] = sanitizeDownloaderValue(sanitized["downloader"], allowTidal)
sanitized["autoOrder"] = sanitizeAutoOrderValue(sanitized["autoOrder"], allowTidal)
return sanitized
}
func CleanupLegacyTidalPublicAPIState() error {
appDir, err := EnsureAppDir()
if err != nil {
return err
}
cachePath := filepath.Join(appDir, legacyTidalAPICacheFile)
if err := os.Remove(cachePath); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
func SanitizePersistedConfigSettings() error {
configPath, err := GetConfigPath()
if err != nil {
return err
}
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return nil
}
data, err := os.ReadFile(configPath)
if err != nil {
return err
}
var settings map[string]interface{}
if err := json.Unmarshal(data, &settings); err != nil {
return err
}
sanitized := SanitizeSettingsMap(settings)
payload, err := json.MarshalIndent(sanitized, "", " ")
if err != nil {
return err
}
return os.WriteFile(configPath, payload, 0o644)
}
func GetDefaultMusicPath() string {
homeDir, err := os.UserHomeDir()
@@ -174,7 +47,7 @@ func LoadConfigSettings() (map[string]interface{}, error) {
return nil, err
}
return SanitizeSettingsMap(settings), nil
return settings, nil
}
func GetRedownloadWithSuffixSetting() bool {
@@ -187,34 +60,6 @@ func GetRedownloadWithSuffixSetting() bool {
return enabled
}
func GetCustomTidalAPISetting() string {
settings, err := LoadConfigSettings()
if err != nil || settings == nil {
return ""
}
return normalizeCustomTidalAPIValue(settings["customTidalApi"])
}
func normalizeExistingFileCheckMode(value string) string {
switch strings.TrimSpace(strings.ToLower(value)) {
case "isrc", "upc":
return "isrc"
default:
return "filename"
}
}
func GetExistingFileCheckModeSetting() string {
settings, err := LoadConfigSettings()
if err != nil || settings == nil {
return "filename"
}
rawMode, _ := settings["existingFileCheckMode"].(string)
return normalizeExistingFileCheckMode(rawMode)
}
func GetLinkResolverSetting() string {
settings, err := LoadConfigSettings()
if err != nil || settings == nil {
-129
View File
@@ -1,129 +0,0 @@
package backend
import (
"context"
"errors"
"fmt"
"sync"
"time"
)
var ErrDownloadCancelled = errors.New("download cancelled")
var downloadCancelState = struct {
sync.Mutex
ctx context.Context
cancel context.CancelFunc
active int
stopping bool
}{}
func BeginDownloadCancellationScope() (context.Context, func()) {
downloadCancelState.Lock()
defer downloadCancelState.Unlock()
if downloadCancelState.ctx == nil || downloadCancelState.active == 0 {
downloadCancelState.ctx, downloadCancelState.cancel = context.WithCancel(context.Background())
downloadCancelState.stopping = false
}
downloadCancelState.active++
ctx := downloadCancelState.ctx
once := sync.Once{}
return ctx, func() {
once.Do(func() {
downloadCancelState.Lock()
defer downloadCancelState.Unlock()
if downloadCancelState.active > 0 {
downloadCancelState.active--
}
if downloadCancelState.active == 0 {
if downloadCancelState.cancel != nil {
downloadCancelState.cancel()
}
downloadCancelState.ctx = nil
downloadCancelState.cancel = nil
downloadCancelState.stopping = false
}
})
}
}
func ActiveDownloadContext() context.Context {
downloadCancelState.Lock()
defer downloadCancelState.Unlock()
if downloadCancelState.ctx == nil {
return context.Background()
}
return downloadCancelState.ctx
}
func ForceStopActiveDownloads() {
downloadCancelState.Lock()
cancel := downloadCancelState.cancel
if cancel != nil {
downloadCancelState.stopping = true
}
downloadCancelState.Unlock()
if cancel != nil {
cancel()
}
CancelQueuedAndDownloadingItems()
SetDownloading(false)
}
func IsDownloadForceStopRequested() bool {
downloadCancelState.Lock()
defer downloadCancelState.Unlock()
return downloadCancelState.stopping
}
func CheckDownloadCancelled() error {
ctx := ActiveDownloadContext()
select {
case <-ctx.Done():
return ErrDownloadCancelled
default:
return nil
}
}
func SleepWithDownloadContext(delay time.Duration) error {
if delay <= 0 {
return CheckDownloadCancelled()
}
ctx := ActiveDownloadContext()
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ErrDownloadCancelled
case <-timer.C:
return nil
}
}
func IsDownloadCancelledError(err error) bool {
if err == nil {
return false
}
return errors.Is(err, ErrDownloadCancelled) || errors.Is(err, context.Canceled)
}
func WrapDownloadCancelled(err error) error {
if err == nil {
return nil
}
if IsDownloadForceStopRequested() || errors.Is(err, context.Canceled) {
return fmt.Errorf("%w", ErrDownloadCancelled)
}
return err
}
+50 -225
View File
@@ -11,7 +11,6 @@ import (
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
@@ -20,11 +19,6 @@ import (
"golang.org/x/text/unicode/norm"
)
type executableCandidate struct {
path string
source string
}
func ValidateExecutable(path string) error {
cleanedPath := filepath.Clean(path)
if cleanedPath == "" {
@@ -89,50 +83,6 @@ func GetFFmpegDir() (string, error) {
return EnsureAppDir()
}
func copyExecutable(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
return err
}
defer out.Close()
if _, err = io.Copy(out, in); err != nil {
return err
}
if err := out.Sync(); err != nil {
return err
}
return prepareExecutableForUse(dst)
}
func appendExecutableCandidate(candidates []executableCandidate, seen map[string]struct{}, path, source string) []executableCandidate {
cleanedPath := filepath.Clean(strings.TrimSpace(path))
if cleanedPath == "" {
return candidates
}
if _, exists := seen[cleanedPath]; exists {
return candidates
}
seen[cleanedPath] = struct{}{}
return append(candidates, executableCandidate{
path: cleanedPath,
source: source,
})
}
func resolveSystemExecutable(executableName string) string {
if runtime.GOOS == "darwin" {
candidates := []string{
@@ -164,163 +114,83 @@ func resolveSystemExecutable(executableName string) string {
return ""
}
func runExecutableVersionCheck(path string) error {
cmd := exec.Command(path, "-version")
setHideWindow(cmd)
return cmd.Run()
}
func removeMacOSQuarantineAttribute(path string) error {
cmd := exec.Command("xattr", "-d", "com.apple.quarantine", path)
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
if err == nil {
return nil
}
trimmedOutput := strings.TrimSpace(string(output))
lowerOutput := strings.ToLower(trimmedOutput)
if strings.Contains(lowerOutput, "no such xattr") || strings.Contains(lowerOutput, "attribute not found") {
return nil
}
if trimmedOutput != "" {
return fmt.Errorf("%w: %s", err, trimmedOutput)
}
return err
}
func prepareExecutableForUse(path string) error {
cleanedPath := filepath.Clean(strings.TrimSpace(path))
if cleanedPath == "" {
return fmt.Errorf("empty path")
}
if runtime.GOOS == "windows" {
return nil
}
if err := os.Chmod(cleanedPath, 0755); err != nil {
return fmt.Errorf("failed to mark executable: %w", err)
}
if runtime.GOOS == "darwin" {
if err := removeMacOSQuarantineAttribute(cleanedPath); err != nil {
fmt.Printf("[FFmpeg] Warning: failed to remove macOS quarantine from %s: %v\n", cleanedPath, err)
}
}
return nil
}
func resolveExecutablePath(executableName string) (string, string, error) {
func GetFFmpegPath() (string, error) {
ffmpegDir, err := GetFFmpegDir()
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"
if runtime.GOOS == "windows" {
ffmpegName = "ffmpeg.exe"
}
path, localPath, err := resolveExecutablePath(ffmpegName)
if err != nil {
if localPath != "" {
return localPath, err
}
return "", err
if path := resolveSystemExecutable(ffmpegName); path != "" {
return path, nil
}
return path, nil
localPath := filepath.Join(ffmpegDir, ffmpegName)
if _, err := os.Stat(localPath); err == nil {
return localPath, nil
}
return localPath, nil
}
func GetFFprobePath() (string, error) {
ffmpegDir, err := GetFFmpegDir()
if err != nil {
return "", err
}
ffprobeName := "ffprobe"
if runtime.GOOS == "windows" {
ffprobeName = "ffprobe.exe"
}
path, localPath, err := resolveExecutablePath(ffprobeName)
if err != nil {
if localPath != "" {
return localPath, err
}
return "", err
if path := resolveSystemExecutable(ffprobeName); path != "" {
return path, nil
}
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) {
_, 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
}
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
}
@@ -374,7 +244,7 @@ func InstallFFmpegWithBrew(progressCallback func(int, string)) error {
return nil
}
const ffmpegReleaseBaseURL = "https://github.com/spotbye/Dependencies/releases/download/FFmpeg-8.1"
const ffmpegReleaseBaseURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.1"
func buildFFmpegReleaseURL(assetName string) string {
return ffmpegReleaseBaseURL + "/" + assetName
@@ -637,10 +507,6 @@ func extractZip(zipPath, destDir string) error {
return fmt.Errorf("failed to extract file: %w", err)
}
if err := prepareExecutableForUse(destPath); err != nil {
return fmt.Errorf("failed to prepare extracted executable: %w", err)
}
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
}
@@ -718,10 +584,6 @@ func extractTarXz(tarXzPath, destDir string) error {
return fmt.Errorf("failed to extract file: %w", err)
}
if err := prepareExecutableForUse(destPath); err != nil {
return fmt.Errorf("failed to prepare extracted executable: %w", err)
}
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
}
@@ -871,36 +733,6 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
"-map", "0:a",
)
}
case "wav", "aiff":
sampleFmt, rawBits := pcmSampleFormatForInput(inputFile)
pcmCodec := "pcm_s16le"
if req.OutputFormat == "aiff" {
pcmCodec = "pcm_s16be"
}
if sampleFmt == "s32" {
if req.OutputFormat == "aiff" {
pcmCodec = "pcm_s24be"
} else {
pcmCodec = "pcm_s24le"
}
}
args = append(args,
"-codec:a", pcmCodec,
"-map", "0:a",
)
if rawBits > 0 {
args = append(args, "-bits_per_raw_sample", strconv.Itoa(rawBits))
}
case "opus":
bitrate := req.Bitrate
if bitrate == "" {
bitrate = "192k"
}
args = append(args,
"-codec:a", "libopus",
"-b:a", bitrate,
"-map", "0:a",
)
}
args = append(args, outputFile)
@@ -955,13 +787,6 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
return results, nil
}
func pcmSampleFormatForInput(inputFile string) (sampleFmt string, rawBits int) {
if meta, err := GetTrackMetadata(inputFile); err == nil && meta != nil && meta.BitsPerSample > 16 {
return "s32", 24
}
return "s16", 0
}
type AudioFileInfo struct {
Path string `json:"path"`
Filename string `json:"filename"`
+8 -9
View File
@@ -149,15 +149,14 @@ func ClearHistory(appName string) error {
}
type FetchHistoryItem struct {
ID string `json:"id"`
URL string `json:"url"`
Type string `json:"type"`
Name string `json:"name"`
Info string `json:"info"`
Image string `json:"image"`
Data string `json:"data"`
IsExplicit bool `json:"is_explicit,omitempty"`
Timestamp int64 `json:"timestamp"`
ID string `json:"id"`
URL string `json:"url"`
Type string `json:"type"`
Name string `json:"name"`
Info string `json:"info"`
Image string `json:"image"`
Data string `json:"data"`
Timestamp int64 `json:"timestamp"`
}
const (
-277
View File
@@ -1,277 +0,0 @@
package backend
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
id3v2 "github.com/bogem/id3v2/v2"
"github.com/go-flac/flacvorbis"
"github.com/go-flac/go-flac"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
type EmbeddedLyrics struct {
Path string `json:"path"`
Name string `json:"name"`
Lyrics string `json:"lyrics"`
Source string `json:"source"`
Synced bool `json:"synced"`
Error string `json:"error,omitempty"`
}
var lrcTimestampRe = regexp.MustCompile(`\[\d{1,2}:\d{2}(?:\.\d{1,3})?\]`)
func isSyncedLyrics(lyrics string) bool {
return lrcTimestampRe.MatchString(lyrics)
}
func ReadEmbeddedLyrics(filePath string) (*EmbeddedLyrics, error) {
if !fileExists(filePath) {
return nil, fmt.Errorf("file does not exist")
}
result := &EmbeddedLyrics{
Path: filePath,
Name: filepath.Base(filePath),
}
ext := strings.ToLower(filepath.Ext(filePath))
var lyrics string
var err error
switch ext {
case ".lrc", ".txt":
var content []byte
content, err = os.ReadFile(filePath)
if err == nil {
lyrics = string(content)
result.Source = "lrc"
}
case ".flac":
lyrics, err = readFlacLyrics(filePath)
result.Source = "embedded"
case ".mp3":
lyrics, err = readMp3Lyrics(filePath)
result.Source = "embedded"
case ".m4a", ".aac", ".opus", ".ogg":
lyrics, err = readLyricsWithFFprobe(filePath)
result.Source = "embedded"
default:
return nil, fmt.Errorf("unsupported file format: %s", ext)
}
if err != nil {
result.Error = err.Error()
return result, nil
}
lyrics = strings.TrimSpace(lyrics)
if lyrics == "" {
result.Error = "No lyrics found in this file"
return result, nil
}
result.Lyrics = lyrics
result.Synced = isSyncedLyrics(lyrics)
return result, nil
}
func readFlacLyrics(filePath string) (string, error) {
f, err := flac.ParseFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
}
for _, block := range f.Meta {
if block.Type != flac.VorbisComment {
continue
}
cmt, err := flacvorbis.ParseFromMetaDataBlock(*block)
if err != nil {
continue
}
for _, comment := range cmt.Comments {
parts := strings.SplitN(comment, "=", 2)
if len(parts) != 2 {
continue
}
fieldName := strings.ToUpper(parts[0])
switch fieldName {
case "LYRICS", "UNSYNCEDLYRICS", "SYNCEDLYRICS", "LYRICS-XXX":
if strings.TrimSpace(parts[1]) != "" {
return parts[1], nil
}
}
}
}
return "", nil
}
func readMp3Lyrics(filePath string) (string, error) {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return "", fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tag.Close()
frames := tag.GetFrames(tag.CommonID("Unsynchronised lyrics/text transcription"))
for _, frame := range frames {
uslf, ok := frame.(id3v2.UnsynchronisedLyricsFrame)
if !ok {
continue
}
if strings.TrimSpace(uslf.Lyrics) != "" {
return uslf.Lyrics, nil
}
}
return "", nil
}
func readLyricsWithFFprobe(filePath string) (string, error) {
ffprobePath, err := GetFFprobePath()
if err != nil {
return "", err
}
if err := ValidateExecutable(ffprobePath); err != nil {
return "", fmt.Errorf("invalid ffprobe executable: %w", err)
}
cmd := exec.Command(ffprobePath,
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
filePath,
)
setHideWindow(cmd)
output, err := cmd.Output()
if err != nil {
return "", err
}
var probe struct {
Format struct {
Tags map[string]string `json:"tags"`
} `json:"format"`
Streams []struct {
Tags map[string]string `json:"tags"`
} `json:"streams"`
}
if err := json.Unmarshal(output, &probe); err != nil {
return "", err
}
collect := func(tags map[string]string) string {
for key, value := range tags {
lk := strings.ToLower(key)
if lk == "lyrics" || strings.HasPrefix(lk, "lyrics-") || lk == "unsyncedlyrics" {
if strings.TrimSpace(value) != "" {
return value
}
}
}
return ""
}
if lyrics := collect(probe.Format.Tags); lyrics != "" {
return lyrics, nil
}
for _, stream := range probe.Streams {
if lyrics := collect(stream.Tags); lyrics != "" {
return lyrics, nil
}
}
return "", nil
}
type ExtractLyricsResult struct {
Path string `json:"path"`
OutputPath string `json:"output_path,omitempty"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
}
func ExtractLyricsToLRC(filePath string, overwrite bool) (*ExtractLyricsResult, error) {
result := &ExtractLyricsResult{Path: filePath}
embedded, err := ReadEmbeddedLyrics(filePath)
if err != nil {
result.Error = err.Error()
return result, nil
}
if embedded.Error != "" {
result.Error = embedded.Error
return result, nil
}
if strings.TrimSpace(embedded.Lyrics) == "" {
result.Error = "No lyrics found in this file"
return result, nil
}
dir := filepath.Dir(filePath)
base := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
outputPath := filepath.Join(dir, base+".lrc")
result.OutputPath = outputPath
if !overwrite {
if info, statErr := os.Stat(outputPath); statErr == nil && info.Size() > 0 {
result.AlreadyExists = true
result.Error = "LRC file already exists"
return result, nil
}
}
content := embedded.Lyrics
if !strings.HasSuffix(content, "\n") {
content += "\n"
}
if writeErr := os.WriteFile(outputPath, []byte(content), 0644); writeErr != nil {
result.Error = fmt.Sprintf("failed to write LRC file: %v", writeErr)
return result, nil
}
result.Success = true
return result, nil
}
func SelectLyricsFiles(ctx context.Context) ([]string, error) {
return runtime.OpenMultipleFilesDialog(ctx, runtime.OpenDialogOptions{
Title: "Select Lyrics or Audio Files",
Filters: []runtime.FileFilter{
{
DisplayName: "Lyrics & Audio (*.lrc, *.flac, *.mp3, *.m4a, *.opus)",
Pattern: "*.lrc;*.flac;*.mp3;*.m4a;*.aac;*.opus;*.ogg;*.txt",
},
{
DisplayName: "LRC Files (*.lrc)",
Pattern: "*.lrc",
},
{
DisplayName: "Audio Files (*.flac, *.mp3, *.m4a, *.opus)",
Pattern: "*.flac;*.mp3;*.m4a;*.aac;*.opus;*.ogg",
},
{
DisplayName: "All Files (*.*)",
Pattern: "*.*",
},
},
})
}
-247
View File
@@ -1,247 +0,0 @@
package backend
import (
"encoding/hex"
"fmt"
"io"
"os"
"strings"
"github.com/Eyevinn/mp4ff/mp4"
)
func decryptWithMP4FF(keySpecs []string, inputPath, outputPath string) error {
key, keysByKID, strictKIDMode, err := parseMP4FFKeySpecs(keySpecs)
if err != nil {
return err
}
inFile, err := os.Open(inputPath)
if err != nil {
return fmt.Errorf("failed to open encrypted MP4: %w", err)
}
defer inFile.Close()
outFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create decrypted MP4: %w", err)
}
outClosed := false
defer func() {
if !outClosed {
_ = outFile.Close()
}
}()
if err := decryptMP4FFFileWithKeyMap(inFile, nil, outFile, key, keysByKID, strictKIDMode); err != nil {
_ = outFile.Close()
outClosed = true
_ = os.Remove(outputPath)
return fmt.Errorf("mp4ff decryption failed: %w", err)
}
if err := outFile.Close(); err != nil {
outClosed = true
_ = os.Remove(outputPath)
return fmt.Errorf("failed to finalize decrypted MP4: %w", err)
}
outClosed = true
return nil
}
func parseMP4FFKeySpecs(keySpecs []string) (key []byte, keysByKID map[string][]byte, strictKIDMode bool, err error) {
normalizedSpecs := make([]string, 0, len(keySpecs))
seenSpecs := make(map[string]struct{}, len(keySpecs))
for _, spec := range keySpecs {
normalized, err := normalizeMP4FFKeySpec(spec)
if err != nil {
return nil, nil, false, err
}
if normalized == "" {
continue
}
if _, ok := seenSpecs[normalized]; ok {
continue
}
seenSpecs[normalized] = struct{}{}
normalizedSpecs = append(normalizedSpecs, normalized)
}
if len(normalizedSpecs) == 0 {
return nil, nil, false, fmt.Errorf("no mp4ff key specs provided")
}
hasKIDPair := false
hasLegacyKey := false
for _, spec := range normalizedSpecs {
if strings.Contains(spec, ":") {
hasKIDPair = true
} else {
hasLegacyKey = true
}
}
if hasKIDPair && hasLegacyKey {
return nil, nil, false, fmt.Errorf("cannot mix legacy key and kid:key key format")
}
if !hasKIDPair {
if len(normalizedSpecs) != 1 {
return nil, nil, false, fmt.Errorf("multiple legacy keys are not supported")
}
key, err = mp4.UnpackKey(normalizedSpecs[0])
if err != nil {
return nil, nil, false, fmt.Errorf("unpacking key: %w", err)
}
return key, nil, false, nil
}
keysByKID = make(map[string][]byte, len(normalizedSpecs))
for _, spec := range normalizedSpecs {
parts := strings.SplitN(spec, ":", 2)
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
return nil, nil, false, fmt.Errorf("bad kid:key format %q", spec)
}
kid, err := mp4.UnpackKey(strings.TrimSpace(parts[0]))
if err != nil {
return nil, nil, false, fmt.Errorf("unpacking kid: %w", err)
}
kidHex := hex.EncodeToString(kid)
if _, exists := keysByKID[kidHex]; exists {
return nil, nil, false, fmt.Errorf("duplicate kid %s", kidHex)
}
parsedKey, err := mp4.UnpackKey(strings.TrimSpace(parts[1]))
if err != nil {
return nil, nil, false, fmt.Errorf("unpacking key for kid %s: %w", kidHex, err)
}
keysByKID[kidHex] = parsedKey
}
return nil, keysByKID, true, nil
}
func normalizeMP4FFKeySpec(spec string) (string, error) {
spec = strings.TrimSpace(spec)
if spec == "" || !strings.Contains(spec, ":") {
return spec, nil
}
parts := strings.SplitN(spec, ":", 2)
left := strings.TrimSpace(parts[0])
right := strings.TrimSpace(parts[1])
if left == "" || right == "" {
return "", fmt.Errorf("bad key spec %q", spec)
}
if _, err := mp4.UnpackKey(left); err == nil {
return left + ":" + right, nil
}
if !isDecimalString(left) {
return "", fmt.Errorf("bad kid in key spec %q", spec)
}
if _, err := mp4.UnpackKey(right); err != nil {
return "", fmt.Errorf("bad key spec %q: %w", spec, err)
}
return right, nil
}
func isDecimalString(value string) bool {
if value == "" {
return false
}
for _, ch := range value {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
func decryptMP4FFFileWithKeyMap(r, initR io.Reader, w io.Writer, key []byte, keysByKID map[string][]byte, strictKIDMode bool) error {
inMp4, err := mp4.DecodeFile(r)
if err != nil {
return err
}
if !inMp4.IsFragmented() {
return fmt.Errorf("file not fragmented. Not supported")
}
init := inMp4.Init
if inMp4.Init == nil {
if initR == nil {
return fmt.Errorf("no init segment file and no init part of file")
}
initSegment, err := mp4.DecodeFile(initR)
if err != nil {
return fmt.Errorf("could not decode init file: %w", err)
}
init = initSegment.Init
}
decryptInfo, err := mp4.DecryptInit(init)
if err != nil {
return err
}
if inMp4.Init != nil {
if err := inMp4.Init.Encode(w); err != nil {
return err
}
}
for _, segment := range inMp4.Segments {
if inMp4.Init == nil {
if err := segment.ParseSenc(init); err != nil {
return fmt.Errorf("parseSenc: %w", err)
}
}
if err := decryptMP4FFSegmentWithSparseSenc(segment, decryptInfo, key, keysByKID, strictKIDMode); err != nil {
return fmt.Errorf("decryptSegment: %w", err)
}
if err := segment.Encode(w); err != nil {
return err
}
}
return nil
}
func decryptMP4FFSegmentWithSparseSenc(segment *mp4.MediaSegment, decryptInfo mp4.DecryptInfo, key []byte, keysByKID map[string][]byte, strictKIDMode bool) error {
for _, fragment := range segment.Fragments {
if !mp4FragmentContainsSenc(fragment) {
continue
}
if err := mp4.DecryptFragmentWithKeys(fragment, decryptInfo, key, keysByKID, strictKIDMode); err != nil {
return err
}
}
if len(segment.Sidxs) > 0 {
segment.Sidx = nil
segment.Sidxs = nil
}
return nil
}
func mp4FragmentContainsSenc(fragment *mp4.Fragment) bool {
if fragment == nil || fragment.Moof == nil {
return false
}
for _, traf := range fragment.Moof.Trafs {
if traf == nil {
continue
}
hasSenc, _ := traf.ContainsSencBox()
if hasSenc {
return true
}
}
return false
}
-61
View File
@@ -41,9 +41,6 @@ var (
currentSpeed float64
speedLock sync.RWMutex
rateLimitUntilMs int64
rateLimitLock sync.RWMutex
downloadQueue []DownloadItem
downloadQueueLock sync.RWMutex
currentItemID string
@@ -58,8 +55,6 @@ type ProgressInfo struct {
IsDownloading bool `json:"is_downloading"`
MBDownloaded float64 `json:"mb_downloaded"`
SpeedMBps float64 `json:"speed_mbps"`
RateLimited bool `json:"rate_limited"`
RateLimitSecs int `json:"rate_limit_secs"`
}
type DownloadQueueInfo struct {
@@ -87,45 +82,13 @@ func GetDownloadProgress() ProgressInfo {
speed := currentSpeed
speedLock.RUnlock()
rateLimitLock.RLock()
untilMs := rateLimitUntilMs
rateLimitLock.RUnlock()
rateLimited := false
rateLimitSecs := 0
if untilMs > 0 {
remainingMs := untilMs - getCurrentTimeMillis()
if remainingMs > 0 {
rateLimited = true
rateLimitSecs = int((remainingMs + 999) / 1000)
}
}
return ProgressInfo{
IsDownloading: downloading,
MBDownloaded: progress,
SpeedMBps: speed,
RateLimited: rateLimited,
RateLimitSecs: rateLimitSecs,
}
}
func SetRateLimitCooldown(seconds float64) {
rateLimitLock.Lock()
if seconds <= 0 {
rateLimitUntilMs = 0
} else {
rateLimitUntilMs = getCurrentTimeMillis() + int64(seconds*1000)
}
rateLimitLock.Unlock()
}
func ClearRateLimitCooldown() {
rateLimitLock.Lock()
rateLimitUntilMs = 0
rateLimitLock.Unlock()
}
func SetDownloadSpeed(mbps float64) {
speedLock.Lock()
currentSpeed = mbps
@@ -147,7 +110,6 @@ func SetDownloading(downloading bool) {
SetDownloadProgress(0)
SetDownloadSpeed(0)
ClearRateLimitCooldown()
}
}
@@ -185,10 +147,6 @@ func getCurrentTimeMillis() int64 {
}
func (pw *ProgressWriter) Write(p []byte) (int, error) {
if err := CheckDownloadCancelled(); err != nil {
return 0, err
}
n, err := pw.writer.Write(p)
pw.total += int64(n)
@@ -438,25 +396,6 @@ func CancelAllQueuedItems() {
}
}
func CancelQueuedAndDownloadingItems() {
downloadQueueLock.Lock()
for i := range downloadQueue {
if downloadQueue[i].Status == StatusQueued || downloadQueue[i].Status == StatusDownloading {
downloadQueue[i].Status = StatusSkipped
downloadQueue[i].EndTime = time.Now().Unix()
downloadQueue[i].ErrorMessage = "Cancelled"
}
}
downloadQueueLock.Unlock()
currentItemLock.Lock()
currentItemID = ""
currentItemLock.Unlock()
SetDownloadProgress(0)
SetDownloadSpeed(0)
}
func ResetSessionIfComplete() {
downloadQueueLock.RLock()
hasActiveOrQueued := false
+6 -77
View File
@@ -1,86 +1,15 @@
package backend
import (
"net/url"
"strings"
)
const amazonMusicAPIBaseURL = "https://amazon.spotbye.qzz.io"
const (
qobuzWJHEBaseURL = "https://music.wjhe.top"
qobuzWJHESearchAPIURL = qobuzWJHEBaseURL + "/api/music/qobuz/search"
qobuzWJHEStreamAPIURL = qobuzWJHEBaseURL + "/api/music/qobuz/url"
qobuzMusicDLDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download"
qobuzGDStudioAPIURLXYZ = "https://music.gdstudio.xyz/api.php"
qobuzGDStudioAPIURLORG = "https://music.gdstudio.org/api.php"
qobuzGDStudioVersion = "2026.5.10"
)
var defaultQobuzDownloadProviderURLs = []string{
qobuzWJHEStreamAPIURL,
qobuzGDStudioAPIURLXYZ,
qobuzGDStudioAPIURLORG,
qobuzMusicDLDownloadAPIURL,
var defaultQobuzStreamAPIBaseURLs = []string{
"https://dab.yeet.su/api/stream?trackId=",
"https://dabmusic.xyz/api/stream?trackId=",
"https://qobuz.spotbye.qzz.io/api/track/",
}
func GetQobuzDownloadProviderURLs() []string {
return append([]string(nil), defaultQobuzDownloadProviderURLs...)
}
func GetQobuzWJHESearchAPIURL() string {
return qobuzWJHESearchAPIURL
}
func GetQobuzWJHEStreamAPIURL() string {
return qobuzWJHEStreamAPIURL
}
func GetQobuzMusicDLDownloadAPIURL() string {
return qobuzMusicDLDownloadAPIURL
}
func GetQobuzGDStudioAPIURLs() []string {
return []string{qobuzGDStudioAPIURLXYZ, qobuzGDStudioAPIURLORG}
}
func GetQobuzGDStudioPrimaryAPIURL() string {
return qobuzGDStudioAPIURLXYZ
}
func GetQobuzGDStudioFallbackAPIURL() string {
return qobuzGDStudioAPIURLORG
}
func GetQobuzGDStudioSignatureHost(apiURL string) string {
parsed, err := url.Parse(strings.TrimSpace(apiURL))
if err != nil || strings.TrimSpace(parsed.Host) == "" {
return ""
}
return strings.TrimSpace(parsed.Host)
}
func GetQobuzGDStudioVersion() string {
return qobuzGDStudioVersion
}
func IsQobuzWJHEProviderURL(raw string) bool {
candidate := strings.TrimSpace(raw)
return candidate == qobuzWJHEStreamAPIURL || strings.HasPrefix(candidate, qobuzWJHEStreamAPIURL+"?")
}
func IsQobuzMusicDLProviderURL(raw string) bool {
return strings.EqualFold(strings.TrimSpace(raw), qobuzMusicDLDownloadAPIURL)
}
func IsQobuzGDStudioProviderURL(raw string) bool {
candidate := strings.TrimSpace(raw)
for _, apiURL := range GetQobuzGDStudioAPIURLs() {
if candidate == apiURL || strings.HasPrefix(candidate, apiURL+"?") {
return true
}
}
return false
func GetQobuzStreamAPIBaseURLs() []string {
return append([]string(nil), defaultQobuzStreamAPIBaseURLs...)
}
func GetAmazonMusicAPIBaseURL() string {
+105 -704
View File
@@ -1,12 +1,6 @@
package backend
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/md5"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
@@ -15,19 +9,23 @@ import (
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
type QobuzDownloader struct {
client *http.Client
customURL string
client *http.Client
appID string
}
func (q *QobuzDownloader) SetCustomAPIURL(apiURL string) {
q.customURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
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 {
@@ -66,63 +64,8 @@ type QobuzTrack struct {
} `json:"album"`
}
type qobuzMusicDLRequest struct {
URL string `json:"url"`
Quality string `json:"quality"`
}
type qobuzMusicDLResponse struct {
Success bool `json:"success"`
Type string `json:"type"`
URLType string `json:"url_type"`
TrackID string `json:"track_id"`
Quality string `json:"quality_label"`
DownloadURL string `json:"download_url"`
Message string `json:"message"`
Error string `json:"error"`
}
type qobuzPublicSearchResponse struct {
Tracks struct {
Total int `json:"total"`
Items []QobuzTrack `json:"items"`
} `json:"tracks"`
}
const qobuzProbeTrackID int64 = 341032040
var (
qobuzMusicDLDebugKeyOnce sync.Once
qobuzMusicDLDebugKey string
qobuzMusicDLDebugKeyErr error
qobuzStreamingURLPattern = regexp.MustCompile(`https?://[^\s"'<>\\)]+`)
)
var qobuzMusicDLDebugKeySeedParts = [][]byte{
{0x73, 0x70, 0x6f, 0x74, 0x69, 0x66},
{0x6c, 0x61, 0x63, 0x3a, 0x71, 0x6f},
{0x62, 0x75, 0x7a, 0x3a, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x64, 0x6c, 0x3a, 0x76, 0x31},
}
var qobuzMusicDLDebugKeyAAD = []byte{
0x71, 0x6f, 0x62, 0x75, 0x7a, 0x7c, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x64,
0x6c, 0x7c, 0x64, 0x65, 0x62, 0x75, 0x67, 0x7c, 0x76, 0x31,
}
var qobuzMusicDLDebugKeyNonce = []byte{
0x91, 0x2a, 0x5c, 0x77, 0x0f, 0x33, 0xa8, 0x14, 0x62, 0x9d, 0xce, 0x41,
}
var qobuzMusicDLDebugKeyCiphertext = []byte{
0xf3, 0x4a, 0x83, 0x45, 0x24, 0xb6, 0x22, 0xaf, 0xd6, 0xc3, 0x6e, 0x2d,
0x56, 0xd1, 0xbb, 0x0b, 0xe9, 0x1b, 0x4f, 0x1c, 0x5f, 0x41, 0x55, 0xc2,
0xc6, 0xdf, 0xad, 0x21, 0x58, 0xfe, 0xd5, 0xb8, 0x2d, 0x29, 0xf9, 0x9e,
0x6f, 0xd6,
}
var qobuzMusicDLDebugKeyTag = []byte{
0x69, 0x0c, 0x42, 0x70, 0x14, 0x83, 0xff, 0x14, 0xc8, 0xbe, 0x17, 0x00,
0x69, 0xb1, 0xfe, 0xbb,
type QobuzStreamResponse struct {
URL string `json:"url"`
}
func NewQobuzDownloader() *QobuzDownloader {
@@ -130,625 +73,119 @@ func NewQobuzDownloader() *QobuzDownloader {
client: &http.Client{
Timeout: 60 * time.Second,
},
appID: qobuzDefaultAPIAppID,
}
}
func previewQobuzResponseBody(body []byte, maxLen int) string {
preview := strings.TrimSpace(string(body))
if len(preview) > maxLen {
return preview[:maxLen] + "..."
}
return preview
}
func buildQobuzOpenTrackURL(trackID int64) string {
return fmt.Sprintf("https://open.qobuz.com/track/%d", trackID)
}
func getQobuzMusicDLDebugKey() (string, error) {
qobuzMusicDLDebugKeyOnce.Do(func() {
hasher := sha256.New()
for _, part := range qobuzMusicDLDebugKeySeedParts {
hasher.Write(part)
}
block, err := aes.NewCipher(hasher.Sum(nil))
if err != nil {
qobuzMusicDLDebugKeyErr = err
return
}
gcm, err := cipher.NewGCM(block)
if err != nil {
qobuzMusicDLDebugKeyErr = err
return
}
sealed := make([]byte, 0, len(qobuzMusicDLDebugKeyCiphertext)+len(qobuzMusicDLDebugKeyTag))
sealed = append(sealed, qobuzMusicDLDebugKeyCiphertext...)
sealed = append(sealed, qobuzMusicDLDebugKeyTag...)
plaintext, err := gcm.Open(nil, qobuzMusicDLDebugKeyNonce, sealed, qobuzMusicDLDebugKeyAAD)
if err != nil {
qobuzMusicDLDebugKeyErr = err
return
}
qobuzMusicDLDebugKey = string(plaintext)
})
if qobuzMusicDLDebugKeyErr != nil {
return "", qobuzMusicDLDebugKeyErr
}
return qobuzMusicDLDebugKey, nil
}
func firstNonEmptyQobuzValue(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
func normalizeQobuzSearchValue(value string) string {
replacer := strings.NewReplacer(
"&", " and ",
"feat.", " ",
"ft.", " ",
"/", " ",
"-", " ",
"_", " ",
)
normalized := strings.ToLower(strings.TrimSpace(value))
normalized = replacer.Replace(normalized)
return strings.Join(strings.Fields(normalized), " ")
}
func qobuzTrackDisplayArtist(track QobuzTrack) string {
return firstNonEmptyQobuzValue(track.Performer.Name, track.Album.Artist.Name)
}
func qobuzTrackSupportsHiRes(track QobuzTrack) bool {
if track.Hires || track.HiresStreamable {
return true
}
return track.MaximumBitDepth >= 24 || track.MaximumSamplingRate > 48
}
func scoreQobuzSearchCandidate(track QobuzTrack, spotifyTrackName string, spotifyArtistName string, spotifyAlbumName string) int {
score := 0
titleNeedle := normalizeQobuzSearchValue(spotifyTrackName)
titleHaystack := normalizeQobuzSearchValue(track.Title)
switch {
case titleNeedle != "" && titleHaystack == titleNeedle:
score += 1000
case titleNeedle != "" && (strings.Contains(titleHaystack, titleNeedle) || strings.Contains(titleNeedle, titleHaystack)):
score += 500
}
artistNeedle := normalizeQobuzSearchValue(spotifyArtistName)
artistHaystack := normalizeQobuzSearchValue(qobuzTrackDisplayArtist(track))
switch {
case artistNeedle != "" && artistHaystack == artistNeedle:
score += 300
case artistNeedle != "" && artistHaystack != "" && (strings.Contains(artistHaystack, artistNeedle) || strings.Contains(artistNeedle, artistHaystack)):
score += 180
}
albumNeedle := normalizeQobuzSearchValue(spotifyAlbumName)
albumHaystack := normalizeQobuzSearchValue(track.Album.Title)
switch {
case albumNeedle != "" && albumHaystack == albumNeedle:
score += 150
case albumNeedle != "" && albumHaystack != "" && (strings.Contains(albumHaystack, albumNeedle) || strings.Contains(albumNeedle, albumHaystack)):
score += 90
}
if qobuzTrackSupportsHiRes(track) {
score += 40
} else if track.MaximumBitDepth >= 16 {
score += 20
}
return score
}
func mapQobuzWJHEQuality(quality string) (int, string) {
switch strings.TrimSpace(quality) {
case "27", "7":
return 2000, "flac"
case "", "6":
return 1000, "flac"
default:
return 320, "mp3"
}
}
func buildQobuzWJHEDownloadURL(trackID int64, quality string) string {
wjheQuality, wjheFormat := mapQobuzWJHEQuality(quality)
params := url.Values{
"ID": {strconv.FormatInt(trackID, 10)},
"quality": {strconv.Itoa(wjheQuality)},
"format": {wjheFormat},
}
return GetQobuzWJHEStreamAPIURL() + "?" + params.Encode()
}
func qobuzURLLooksStreamable(raw string) bool {
candidate := strings.TrimSpace(raw)
if candidate == "" {
return false
}
parsed, err := url.Parse(candidate)
if err != nil {
return false
}
return (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != ""
}
func findQobuzStreamingURLInPayload(payload interface{}) string {
switch value := payload.(type) {
case string:
candidate := strings.ReplaceAll(strings.TrimSpace(value), `\/`, `/`)
if qobuzURLLooksStreamable(candidate) {
return candidate
}
case []interface{}:
for _, item := range value {
if url := findQobuzStreamingURLInPayload(item); url != "" {
return url
}
}
case map[string]interface{}:
for _, key := range []string{"download_url", "url", "play_url", "stream_url", "link", "file"} {
if nested, ok := value[key]; ok {
if url := findQobuzStreamingURLInPayload(nested); url != "" {
return url
}
}
}
for _, nested := range value {
if url := findQobuzStreamingURLInPayload(nested); url != "" {
return url
}
}
}
return ""
}
func extractQobuzStreamingURL(body []byte) string {
trimmed := strings.TrimSpace(string(body))
if trimmed == "" {
return ""
}
var directResp struct {
URL string `json:"url"`
DownloadURL string `json:"download_url"`
Data struct {
URL string `json:"url"`
DownloadURL string `json:"download_url"`
} `json:"data"`
}
if err := json.Unmarshal(body, &directResp); err == nil {
for _, candidate := range []string{
directResp.DownloadURL,
directResp.URL,
directResp.Data.DownloadURL,
directResp.Data.URL,
} {
if qobuzURLLooksStreamable(candidate) {
return candidate
}
}
}
var genericPayload interface{}
if err := json.Unmarshal(body, &genericPayload); err == nil {
if streamURL := findQobuzStreamingURLInPayload(genericPayload); streamURL != "" {
return streamURL
}
}
if openIdx := strings.Index(trimmed, "("); openIdx >= 0 {
if closeIdx := strings.LastIndex(trimmed, ")"); closeIdx > openIdx+1 {
callbackBody := strings.TrimSpace(trimmed[openIdx+1 : closeIdx])
if streamURL := extractQobuzStreamingURL([]byte(callbackBody)); streamURL != "" {
return streamURL
}
}
}
for _, match := range qobuzStreamingURLPattern.FindAllString(trimmed, -1) {
candidate := strings.ReplaceAll(match, `\/`, `/`)
if qobuzURLLooksStreamable(candidate) {
return candidate
}
}
return ""
}
func newQobuzNoRedirectClient(base *http.Client) *http.Client {
if base == nil {
return &http.Client{
Timeout: 20 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
cloned := *base
if cloned.Timeout == 0 {
cloned.Timeout = 20 * time.Second
}
cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
return &cloned
}
func (q *QobuzDownloader) searchByISRC(isrc string, spotifyTrackName string, spotifyArtistName string, spotifyAlbumName string) (*QobuzTrack, error) {
func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
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)
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()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("Qobuz public API track/get returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
var trackResp QobuzTrack
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
}
queries := []string{strings.TrimSpace(isrc)}
if fallbackQuery := strings.TrimSpace(strings.Join([]string{spotifyTrackName, spotifyArtistName}, " ")); fallbackQuery != "" {
queries = append(queries, fallbackQuery)
}
var lastErr error
for _, query := range queries {
if strings.TrimSpace(query) == "" {
continue
}
var searchResp qobuzPublicSearchResponse
if err := doQobuzSignedJSONRequest("track/search", url.Values{
"query": {strings.TrimSpace(query)},
"limit": {"10"},
}, &searchResp); err != nil {
lastErr = fmt.Errorf("failed to search Qobuz public API: %w", err)
continue
}
if searchResp.Tracks.Total == 0 || len(searchResp.Tracks.Items) == 0 {
lastErr = fmt.Errorf("track not found for query: %s", query)
continue
}
bestIndex := 0
bestScore := -1
for idx, candidate := range searchResp.Tracks.Items {
score := scoreQobuzSearchCandidate(candidate, spotifyTrackName, spotifyArtistName, spotifyAlbumName)
if idx == 0 || score > bestScore {
bestIndex = idx
bestScore = score
}
}
selected := searchResp.Tracks.Items[bestIndex]
return &selected, nil
}
if lastErr == nil {
lastErr = fmt.Errorf("track not found for ISRC: %s", isrc)
}
return nil, lastErr
}
func (q *QobuzDownloader) DownloadFromWJHE(trackID int64, quality string) (string, error) {
apiURL := buildQobuzWJHEDownloadURL(trackID, quality)
client := newQobuzNoRedirectClient(q.client)
req, err := NewRequestWithDefaultHeaders(http.MethodHead, apiURL, nil)
resp, err := doQobuzSignedRequest(http.MethodGet, "track/search", url.Values{
"query": {isrc},
"limit": {"1"},
}, q.client)
if err != nil {
return "", fmt.Errorf("failed to create WJHE request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to reach WJHE: %w", err)
}
if resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotImplemented {
resp.Body.Close()
req, err = NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create WJHE fallback request: %w", err)
}
resp, err = client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to reach WJHE with GET fallback: %w", err)
}
return nil, fmt.Errorf("failed to search track: %w", err)
}
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 {
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)
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()
var searchResp QobuzSearchResponse
body, err := io.ReadAll(resp.Body)
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 {
return "", fmt.Errorf("MusicDL returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
if len(body) == 0 {
return nil, fmt.Errorf("API returned empty response")
}
var downloadResp qobuzMusicDLResponse
if err := json.Unmarshal(body, &downloadResp); err != nil {
return "", fmt.Errorf("failed to decode MusicDL response: %w (%s)", err, previewQobuzResponseBody(body, 256))
}
if err := json.Unmarshal(body, &searchResp); err != nil {
if !downloadResp.Success {
message := strings.TrimSpace(downloadResp.Error)
if message == "" {
message = strings.TrimSpace(downloadResp.Message)
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
}
if message == "" {
message = "MusicDL reported failure"
}
return "", fmt.Errorf("%s", message)
return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
}
downloadURL := strings.TrimSpace(downloadResp.DownloadURL)
if downloadURL == "" {
return "", fmt.Errorf("MusicDL response did not include a download_url")
if len(searchResp.Tracks.Items) == 0 {
return nil, fmt.Errorf("track not found for ISRC: %s", isrc)
}
return downloadURL, nil
return &searchResp.Tracks.Items[0], nil
}
func CheckQobuzMusicDLStatusDetailed(client *http.Client) error {
if client == nil {
client = &http.Client{Timeout: 4 * time.Second}
func buildQobuzAPIURL(apiBase string, trackID int64, quality string) string {
if strings.Contains(apiBase, "qobuz.spotbye.qzz.io") {
return fmt.Sprintf("%s%d?quality=%s", apiBase, trackID, quality)
}
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}
_, err := downloader.DownloadFromMusicDL(qobuzProbeTrackID, "27")
return err
}
resp, err := q.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
func CheckQobuzMusicDLStatus(client *http.Client) bool {
return CheckQobuzMusicDLStatusDetailed(client) == nil
}
func CheckQobuzWJHEStatusDetailed(client *http.Client) error {
if client == nil {
client = &http.Client{Timeout: 4 * time.Second}
if resp.StatusCode != 200 {
return "", fmt.Errorf("status %d", resp.StatusCode)
}
downloader := &QobuzDownloader{client: client}
_, err := downloader.DownloadFromWJHE(qobuzProbeTrackID, "27")
return err
}
func CheckQobuzWJHEStatus(client *http.Client) bool {
return CheckQobuzWJHEStatusDetailed(client) == nil
}
func CheckQobuzGDStudioAPIStatusDetailed(client *http.Client, apiURL string) error {
if client == nil {
client = &http.Client{Timeout: 4 * time.Second}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
downloader := &QobuzDownloader{client: client}
_, err := downloader.DownloadFromGDStudio(qobuzProbeTrackID, "27", apiURL)
return err
}
if len(body) == 0 {
return "", fmt.Errorf("empty body")
}
func CheckQobuzGDStudioAPIStatus(client *http.Client, apiURL string) bool {
return CheckQobuzGDStudioAPIStatusDetailed(client, apiURL) == nil
var streamResp QobuzStreamResponse
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) {
@@ -759,62 +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)
if strings.TrimSpace(q.customURL) != "" {
fmt.Printf("Trying custom Qobuz instance...\n")
url, err := q.getQobuzCustomDownloadURL(trackID, qualityCode)
if err == nil {
fmt.Printf("Success (custom Qobuz instance)\n")
return url, nil
}
if IsDownloadCancelledError(err) {
return "", err
}
fmt.Printf("Custom Qobuz instance failed: %v\n", err)
if !allowFallback {
return "", err
}
}
standardAPIs := prioritizeProviders("qobuz", GetQobuzStreamAPIBaseURLs())
downloadFunc := func(qual string) (string, error) {
if url, err := q.getQobuzCommunityDownloadURL(trackID, qual); err == nil {
fmt.Printf("Success (community qbz-a)\n")
return url, nil
} else if IsDownloadCancelledError(err) {
return "", err
} else {
fmt.Printf("Community qbz-a failed: %v\n", err)
type Provider struct {
Name string
API string
Func func() (string, error)
}
attemptMap := make(map[string]qobuzProviderAttempt)
attemptIDs := make([]string, 0, len(GetQobuzDownloadProviderURLs()))
for _, provider := range q.getQobuzDownloadProviders() {
for _, attempt := range provider.Attempts(trackID, qual) {
attemptMap[attempt.ID] = attempt
attemptIDs = append(attemptIDs, attempt.ID)
}
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
for _, providerID := range orderedProviderIDs {
attempt, ok := attemptMap[providerID]
if !ok {
continue
}
for _, p := range providers {
fmt.Printf("Trying Provider: %s (Quality: %s)...\n", p.Name, qual)
fmt.Printf("Trying Provider: %s (Quality: %s)...\n", attempt.Name, qual)
url, err := attempt.Download()
url, err := p.Func()
if err == nil {
fmt.Printf("Success\n")
recordProviderSuccess("qobuz", attempt.ID)
fmt.Printf("Success\n")
recordProviderSuccess("qobuz", p.API)
return url, nil
}
fmt.Printf("Provider failed: %v\n", err)
recordProviderFailure("qobuz", attempt.ID)
recordProviderFailure("qobuz", p.API)
lastErr = err
}
return "", lastErr
@@ -824,36 +240,27 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
if err == nil {
return url, nil
}
if IsDownloadCancelledError(err) {
return "", err
}
currentQuality := qualityCode
if currentQuality == "27" && allowFallback {
fmt.Printf("Download with quality 27 failed, trying fallback to 7 (24-bit Standard)...\n")
fmt.Printf("Download with quality 27 failed, trying fallback to 7 (24-bit Standard)...\n")
url, err := downloadFunc("7")
if err == nil {
fmt.Println("Success with fallback quality 7")
fmt.Println("Success with fallback quality 7")
return url, nil
}
if IsDownloadCancelledError(err) {
return "", err
}
currentQuality = "7"
}
if currentQuality == "7" && allowFallback {
fmt.Printf("Download with quality 7 failed, trying fallback to 6 (16-bit Lossless)...\n")
fmt.Printf("Download with quality 7 failed, trying fallback to 6 (16-bit Lossless)...\n")
url, err := downloadFunc("6")
if err == nil {
fmt.Println("Success with fallback quality 6")
fmt.Println("Success with fallback quality 6")
return url, nil
}
if IsDownloadCancelledError(err) {
return "", err
}
}
return "", fmt.Errorf("all APIs and fallbacks failed. Last error: %v", err)
@@ -1018,7 +425,7 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena
} else {
fmt.Println("Fetching MusicBrainz metadata...")
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
fmt.Println("MusicBrainz metadata fetched")
fmt.Println("MusicBrainz metadata fetched")
metaChan <- fetchedMeta
} else {
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
@@ -1036,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 {
return "", err
}
@@ -1050,13 +457,7 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena
qualityInfo := "Standard"
if track.Hires {
if track.MaximumBitDepth > 0 && track.MaximumSamplingRate > 0 {
qualityInfo = fmt.Sprintf("Hi-Res (%d-bit / %.1f kHz)", track.MaximumBitDepth, track.MaximumSamplingRate)
} else if track.MaximumBitDepth > 0 {
qualityInfo = fmt.Sprintf("Hi-Res available (%d-bit)", track.MaximumBitDepth)
} else {
qualityInfo = "Hi-Res available"
}
qualityInfo = fmt.Sprintf("Hi-Res (%d-bit / %.1f kHz)", track.MaximumBitDepth, track.MaximumSamplingRate)
}
fmt.Printf("Quality: %s\n", qualityInfo)
-114
View File
@@ -1,114 +0,0 @@
package backend
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
func mapQobuzQualityToCommunity(quality string) string {
switch strings.TrimSpace(quality) {
case "27", "7":
return "24"
default:
return "16"
}
}
func (q *QobuzDownloader) getQobuzCommunityDownloadURL(trackID int64, quality string) (string, error) {
payload, err := json.Marshal(map[string]string{
"id": fmt.Sprintf("%d", trackID),
"quality": mapQobuzQualityToCommunity(quality),
})
if err != nil {
return "", err
}
resp, err := doCommunityRequest(q.client, "Qobuz", func() (*http.Request, error) {
req, err := NewRequestWithDefaultHeaders(http.MethodPost, GetQobuzCommunityDownloadURL(), bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if err := setCommunityRequestHeaders(req); err != nil {
return nil, err
}
return req, nil
})
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("qobuz community API returned status %d", resp.StatusCode)
}
downloadURL := extractQobuzStreamingURL(body)
if downloadURL == "" {
return "", fmt.Errorf("no streamable URL in qobuz community response")
}
return downloadURL, nil
}
func (q *QobuzDownloader) getQobuzCustomDownloadURL(trackID int64, quality string) (string, error) {
base := strings.TrimRight(strings.TrimSpace(q.customURL), "/")
if base == "" {
return "", fmt.Errorf("no custom Qobuz instance configured")
}
qualityCode := strings.TrimSpace(quality)
switch qualityCode {
case "5", "6", "7", "27":
default:
qualityCode = "27"
}
endpoint := fmt.Sprintf("%s/api/download-music?track_id=%d&quality=%s", base, trackID, url.QueryEscape(qualityCode))
req, err := NewRequestWithDefaultHeaders(http.MethodGet, endpoint, nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/json")
resp, err := q.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("qobuz custom instance returned status %d", resp.StatusCode)
}
var parsed struct {
Success bool `json:"success"`
Data struct {
URL string `json:"url"`
} `json:"data"`
Error string `json:"error"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return "", fmt.Errorf("failed to decode qobuz custom response: %w", err)
}
if !parsed.Success || strings.TrimSpace(parsed.Data.URL) == "" {
if strings.TrimSpace(parsed.Error) != "" {
return "", fmt.Errorf("qobuz custom instance error: %s", parsed.Error)
}
return "", fmt.Errorf("no download URL in qobuz custom response")
}
return strings.TrimSpace(parsed.Data.URL), nil
}
-106
View File
@@ -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...)
}
+7 -8
View File
@@ -11,14 +11,13 @@ import (
const recentFetchesFileName = "recent_fetches.json"
type RecentFetchItem struct {
ID string `json:"id"`
URL string `json:"url"`
Type string `json:"type"`
Name string `json:"name"`
Artist string `json:"artist"`
Image string `json:"image"`
IsExplicit bool `json:"is_explicit,omitempty"`
Timestamp int64 `json:"timestamp"`
ID string `json:"id"`
URL string `json:"url"`
Type string `json:"type"`
Name string `json:"name"`
Artist string `json:"artist"`
Image string `json:"image"`
Timestamp int64 `json:"timestamp"`
}
var (
+3 -3
View File
@@ -420,17 +420,17 @@ func mergeSongLinkResponse(links *resolvedTrackLinks, resp *songLinkAPIResponse)
if link, ok := resp.LinksByPlatform["tidal"]; ok && link.URL != "" && links.TidalURL == "" {
links.TidalURL = strings.TrimSpace(link.URL)
fmt.Println("Tidal URL found")
fmt.Println("Tidal URL found")
}
if link, ok := resp.LinksByPlatform["amazonMusic"]; ok && link.URL != "" && links.AmazonURL == "" {
links.AmazonURL = normalizeAmazonMusicURL(link.URL)
fmt.Println("Amazon URL found")
fmt.Println("Amazon URL found")
}
if link, ok := resp.LinksByPlatform["deezer"]; ok && link.URL != "" && links.DeezerURL == "" {
links.DeezerURL = normalizeDeezerTrackURL(link.URL)
fmt.Println("Deezer URL found")
fmt.Println("Deezer URL found")
}
}
+3 -3
View File
@@ -110,19 +110,19 @@ func assignSongstatsLink(rawLink string, links *resolvedTrackLinks) {
case strings.Contains(link, "listen.tidal.com/track"):
if links.TidalURL == "" {
links.TidalURL = link
fmt.Println("Tidal URL found via Songstats")
fmt.Println("Tidal URL found via Songstats")
}
case strings.Contains(link, "music.amazon.com"):
if links.AmazonURL == "" {
if normalized := normalizeAmazonMusicURL(link); normalized != "" {
links.AmazonURL = normalized
fmt.Println("Amazon URL found via Songstats")
fmt.Println("Amazon URL found via Songstats")
}
}
case strings.Contains(link, "deezer.com"):
if links.DeezerURL == "" {
links.DeezerURL = normalizeDeezerTrackURL(link)
fmt.Println("Deezer URL found via Songstats")
fmt.Println("Deezer URL found via Songstats")
}
}
}
+5 -28
View File
@@ -103,7 +103,6 @@ type AlbumInfoMetadata struct {
ReleaseDate string `json:"release_date"`
Artists string `json:"artists"`
Images string `json:"images"`
IsExplicit bool `json:"is_explicit,omitempty"`
UPC string `json:"upc,omitempty"`
Batch string `json:"batch,omitempty"`
ArtistID string `json:"artist_id,omitempty"`
@@ -163,7 +162,6 @@ type DiscographyAlbumMetadata struct {
Artists string `json:"artists"`
Images string `json:"images"`
ExternalURL string `json:"external_urls"`
IsExplicit bool `json:"is_explicit,omitempty"`
}
type ArtistDiscographyPayload struct {
@@ -1106,21 +1104,12 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse, callback
break
}
albumExplicit := false
for _, track := range raw.Tracks {
if track.IsExplicit {
albumExplicit = true
break
}
}
info := AlbumInfoMetadata{
TotalTracks: raw.Count,
Name: raw.Name,
ReleaseDate: raw.ReleaseDate,
Artists: raw.Artists,
Images: raw.Cover,
IsExplicit: albumExplicit,
UPC: raw.UPC,
ArtistID: artistID,
ArtistURL: artistURL,
@@ -1287,10 +1276,8 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
allTracks := make([]AlbumTrackMetadata, 0)
type fetchResult struct {
albumID string
tracks []AlbumTrackMetadata
isExplicit bool
err error
tracks []AlbumTrackMetadata
err error
}
resultsChan := make(chan fetchResult, len(raw.Discography.All))
@@ -1331,7 +1318,7 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
select {
case <-ctx.Done():
resultsChan <- fetchResult{albumID: albumID, err: ctx.Err()}
resultsChan <- fetchResult{err: ctx.Err()}
return
default:
}
@@ -1339,18 +1326,14 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
albumData, err := c.fetchAlbumWithClient(ctx, sharedClient, albumID, nil)
if err != nil {
fmt.Printf("Error getting tracks for album %s: %v\n", albumName, err)
resultsChan <- fetchResult{albumID: albumID, tracks: []AlbumTrackMetadata{}}
resultsChan <- fetchResult{tracks: []AlbumTrackMetadata{}}
return
}
tracks := make([]AlbumTrackMetadata, 0, len(albumData.Tracks))
albumExplicit := false
for idx, tr := range albumData.Tracks {
durationMS := parseDuration(tr.Duration)
trackNumber := idx + 1
if tr.IsExplicit {
albumExplicit = true
}
var artistID, artistURL string
if len(tr.ArtistIds) > 0 {
@@ -1394,7 +1377,7 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
if callback != nil {
callback(tracks)
}
resultsChan <- fetchResult{albumID: albumID, tracks: tracks, isExplicit: albumExplicit}
resultsChan <- fetchResult{tracks: tracks}
}(alb.ID, alb.Name)
}
@@ -1403,12 +1386,6 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
if res.err != nil {
return nil, res.err
}
for albumIndex := range albumList {
if albumList[albumIndex].ID == res.albumID {
albumList[albumIndex].IsExplicit = res.isExplicit
break
}
}
allTracks = append(allTracks, res.tracks...)
}
+51 -193
View File
@@ -9,7 +9,6 @@ import (
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
@@ -48,154 +47,15 @@ type TidalBTSManifest struct {
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 {
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
if apiURL == "" {
apis, err := GetRotatedTidalAPIList()
if err == nil && len(apis) > 0 {
apiURL = apis[0]
}
}
return &TidalDownloader{
client: &http.Client{
Timeout: 5 * time.Second,
@@ -207,7 +67,7 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
}
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
apis, err := getConfiguredTidalAPIAttemptList()
apis, err := GetRotatedTidalAPIList()
if err == nil && len(apis) > 0 {
return apis, nil
}
@@ -252,41 +112,37 @@ func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
fmt.Println("Fetching URL...")
if strings.TrimSpace(t.apiURL) == "" {
fmt.Println("No custom Tidal instance configured, using community tdl-a endpoint")
return t.getTidalCommunityDownloadURL(trackID, quality)
}
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality)
fmt.Printf("Tidal API URL: %s\n", url)
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
if err != nil {
fmt.Printf("failed to create request: %v\n", err)
fmt.Printf("failed to create request: %v\n", err)
return "", fmt.Errorf("failed to create request: %w", err)
}
resp, err := t.client.Do(req)
if err != nil {
fmt.Printf("Tidal API request failed: %v\n", err)
fmt.Printf("Tidal API request failed: %v\n", err)
return "", fmt.Errorf("failed to get download URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
fmt.Printf("Tidal API returned status code: %d\n", resp.StatusCode)
fmt.Printf("Tidal API returned status code: %d\n", resp.StatusCode)
return "", fmt.Errorf("API returned status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("Failed to read response body: %v\n", err)
fmt.Printf("Failed to read response body: %v\n", err)
return "", fmt.Errorf("failed to read response: %w", err)
}
var v2Response TidalAPIResponseV2
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
fmt.Println("Tidal manifest found (v2 API)")
fmt.Println("Tidal manifest found (v2 API)")
return "MANIFEST:" + v2Response.Data.Manifest, nil
}
@@ -297,30 +153,30 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string,
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
}
fmt.Printf("Failed to decode Tidal API response: %v (response: %s)\n", err, bodyStr)
fmt.Printf("Failed to decode Tidal API response: %v (response: %s)\n", err, bodyStr)
return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
}
if len(apiResponses) == 0 {
fmt.Println("Tidal API returned empty response")
fmt.Println("Tidal API returned empty response")
return "", fmt.Errorf("no download URL in response")
}
for _, item := range apiResponses {
if item.OriginalTrackURL != "" {
fmt.Println("Tidal download URL found")
fmt.Println("Tidal download URL found")
return item.OriginalTrackURL, nil
}
}
fmt.Println("No valid download URL in Tidal API response")
fmt.Println("No valid download URL in Tidal API 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:") {
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath, quality)
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath)
}
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
@@ -328,8 +184,7 @@ func (t *TidalDownloader) DownloadFile(url, filepath string, quality string) err
return fmt.Errorf("failed to create request: %w", err)
}
downloadClient := &http.Client{Timeout: 5 * time.Minute}
resp, err := downloadClient.Do(req)
resp, err := t.client.Do(req)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
@@ -358,18 +213,12 @@ func (t *TidalDownloader) DownloadFile(url, filepath string, quality string) err
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)
if err != nil {
return fmt.Errorf("failed to parse manifest: %w", err)
}
isLosslessRequested := quality == "LOSSLESS" || quality == "HI_RES" || quality == "HI_RES_LOSSLESS"
isActualLossless := strings.Contains(strings.ToLower(mimeType), "flac") || mimeType == ""
if isLosslessRequested && !isActualLossless {
return fmt.Errorf("requested %s quality but Tidal provided lossy format (%s). Aborting download", quality, mimeType)
}
client := &http.Client{
Timeout: 120 * time.Second,
}
@@ -572,11 +421,8 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
downloadURL, err := t.GetDownloadURL(trackID, quality)
if err != nil {
if IsDownloadCancelledError(err) {
return outputFilename, err
}
if isTidalHiResQuality(quality) && allowFallback {
fmt.Println("HI_RES unavailable/failed, falling back to LOSSLESS...")
fmt.Println("HI_RES unavailable/failed, falling back to LOSSLESS...")
downloadURL, err = t.GetDownloadURL(trackID, "LOSSLESS")
if err != nil {
return outputFilename, fmt.Errorf("failed to get download URL (HI_RES & LOSSLESS both failed): %w", err)
@@ -587,15 +433,20 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
}
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)
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)
fmt.Println("Done")
fmt.Println("Downloaded successfully from Tidal")
fmt.Println("Downloaded successfully from Tidal")
return outputFilename, nil
}
@@ -626,12 +477,12 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
cleanupTidalDownloadArtifacts(outputFilename)
return outputFilename, err
}
fmt.Printf("Downloaded using API: %s\n", successAPI)
fmt.Printf("Downloaded using API: %s\n", successAPI)
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")
fmt.Println("Downloaded successfully from Tidal")
return outputFilename, nil
}
@@ -642,7 +493,7 @@ func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameF
return "", fmt.Errorf("songlink/songstats couldn't find Tidal URL: %w", err)
}
return t.DownloadByURL(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
}
type SegmentTemplate struct {
@@ -699,12 +550,10 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
var mpd MPD
var segTemplate *SegmentTemplate
var dashMimeType string
if err := xml.Unmarshal(manifestBytes, &mpd); err == nil {
var selectedBandwidth int
var selectedCodecs string
var selectedMimeType string
for _, as := range mpd.Period.AdaptationSets {
@@ -713,7 +562,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
if segTemplate == nil {
segTemplate = as.SegmentTemplate
selectedCodecs = as.Codecs
selectedMimeType = as.MimeType
}
}
@@ -728,8 +576,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
} else {
selectedCodecs = as.Codecs
}
selectedMimeType = as.MimeType
}
}
}
@@ -737,7 +583,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
if selectedBandwidth > 0 {
fmt.Printf("Selected stream: Codec=%s, Bandwidth=%d bps\n", selectedCodecs, selectedBandwidth)
dashMimeType = fmt.Sprintf("%s; codecs=\"%s\"", selectedMimeType, selectedCodecs)
}
}
@@ -763,7 +608,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
mediaURLs = append(mediaURLs, mediaURL)
}
return "", initURL, mediaURLs, dashMimeType, nil
return "", initURL, mediaURLs, "", nil
}
fmt.Println("Using regex fallback for DASH manifest...")
@@ -810,7 +655,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
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) {
@@ -822,7 +667,7 @@ func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename
var lastErr error
for idx, candidateQuality := range qualities {
if idx > 0 {
fmt.Printf("%s unavailable/failed on all APIs, falling back to %s...\n", quality, candidateQuality)
fmt.Printf("%s unavailable/failed on all APIs, falling back to %s...\n", quality, candidateQuality)
}
apiURL, err := t.tryDownloadAcrossTidalAPIs(trackID, outputFilename, candidateQuality, false)
@@ -839,7 +684,7 @@ func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename
}
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 {
return "", fmt.Errorf("failed to load tidal api list: %w", err)
}
@@ -861,23 +706,36 @@ func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilena
continue
}
if err := downloader.DownloadFile(downloadURL, outputFilename, quality); err != nil {
if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil {
lastErr = err
cleanupTidalDownloadArtifacts(outputFilename)
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err))
continue
}
if err := RememberTidalAPIUsage(apiURL); err != nil {
fmt.Printf("Warning: failed to persist last used Tidal API: %v\n", err)
}
return apiURL, nil
}
if !refreshed {
if _, refreshErr := RefreshTidalAPIList(true); refreshErr != nil {
errors = append(errors, fmt.Sprintf("gist refresh failed: %v", refreshErr))
} else {
fmt.Println("All cached Tidal APIs failed, refreshed gist list and retrying...")
return t.tryDownloadAcrossTidalAPIs(trackID, outputFilename, quality, true)
}
}
if lastErr == nil {
lastErr = fmt.Errorf("all tidal apis failed")
}
fmt.Println("All Tidal APIs failed:")
for _, item := range errors {
fmt.Printf(" %s\n", item)
fmt.Printf(" %s\n", item)
}
return "", fmt.Errorf("all tidal apis failed for quality %s: %w", quality, lastErr)
+238
View File
@@ -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
}
+296
View File
@@ -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
}
-80
View File
@@ -1,80 +0,0 @@
package backend
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type tidalCommunityResponse struct {
Quality string `json:"quality"`
URL string `json:"url"`
Lyric string `json:"lyric"`
}
var tidalCommunityClient = &http.Client{Timeout: 60 * time.Second}
func mapTidalQualityToCommunity(quality string) string {
switch strings.ToUpper(strings.TrimSpace(quality)) {
case "HI_RES_LOSSLESS", "HI_RES", "24":
return "24"
default:
return "16"
}
}
func (t *TidalDownloader) getTidalCommunityDownloadURL(trackID int64, quality string) (string, error) {
payload, err := json.Marshal(map[string]string{
"id": fmt.Sprintf("%d", trackID),
"quality": mapTidalQualityToCommunity(quality),
})
if err != nil {
return "", err
}
resp, err := doCommunityRequest(tidalCommunityClient, "Tidal", func() (*http.Request, error) {
req, err := NewRequestWithDefaultHeaders(http.MethodPost, GetTidalCommunityDownloadURL(), bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if err := setCommunityRequestHeaders(req); err != nil {
return nil, err
}
return req, nil
})
if err != nil {
fmt.Printf("Tidal community request failed: %v\n", err)
return "", fmt.Errorf("failed to get download URL: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
preview := string(body)
if len(preview) > 200 {
preview = preview[:200]
}
fmt.Printf("Tidal community API status %d: %s\n", resp.StatusCode, preview)
return "", fmt.Errorf("tidal community API returned status %d: %s", resp.StatusCode, preview)
}
var parsed tidalCommunityResponse
if err := json.Unmarshal(body, &parsed); err != nil {
return "", fmt.Errorf("failed to decode tidal community response: %w", err)
}
if strings.TrimSpace(parsed.URL) == "" {
return "", fmt.Errorf("no download URL in tidal community response")
}
fmt.Printf("Tidal community URL found (quality %s)\n", parsed.Quality)
return parsed.URL, nil
}
+1 -2
View File
@@ -20,7 +20,6 @@
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
@@ -56,4 +55,4 @@
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1"
}
}
}
+1 -1
View File
@@ -1 +1 @@
8864b4f7b7971b624d1ba25030f2db4e
867c45db7982e126a7249d80210f23be
-3
View File
@@ -32,9 +32,6 @@ importers:
'@radix-ui/react-select':
specifier: ^2.2.6
version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-slider':
specifier: ^1.3.6
version: 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-slot':
specifier: ^1.2.4
version: 1.2.4(@types/react@19.2.14)(react@19.2.4)
+2 -2
View File
@@ -14,10 +14,10 @@ async function generateIcon() {
.resize(1024, 1024)
.png()
.toFile(outputPath);
console.log('Icon generated:', outputPath);
console.log('Icon generated:', outputPath);
}
catch (error) {
console.error('Failed to generate icon:', error.message);
console.error('Failed to generate icon:', error.message);
process.exit(1);
}
}
+16 -79
View File
@@ -5,14 +5,12 @@ import { Search, X, ArrowUp } from "lucide-react";
import { TooltipProvider } from "@/components/ui/tooltip";
import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont } from "@/lib/settings";
import { applyTheme } from "@/lib/themes";
import { openExternal } from "@/lib/utils";
import { OpenFolder, CheckFFmpegInstalled, DownloadFFmpeg, GetRecentFetches, SaveRecentFetches } from "../wailsjs/go/main/App";
import { EventsOn, EventsOff, Quit } from "../wailsjs/runtime/runtime";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { TitleBar } from "@/components/TitleBar";
import { Sidebar, type PageType } from "@/components/Sidebar";
import { Header } from "@/components/Header";
import { MarkdownLite, extractMarkdownSection } from "@/components/MarkdownLite";
import { SearchBar } from "@/components/SearchBar";
import { TrackInfo } from "@/components/TrackInfo";
import { AlbumInfo } from "@/components/AlbumInfo";
@@ -24,19 +22,17 @@ import { AudioAnalysisPage } from "@/components/AudioAnalysisPage";
import { AudioConverterPage } from "@/components/AudioConverterPage";
import { AudioResamplerPage } from "@/components/AudioResamplerPage";
import { FileManagerPage } from "@/components/FileManagerPage";
import { LyricsManagerPage } from "@/components/LyricsManagerPage";
import { SettingsPage } from "@/components/SettingsPage";
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
import { OtherProjects } from "@/components/OtherProjects";
import { AboutPage } from "@/components/AboutPage";
import { HistoryPage } from "@/components/HistoryPage";
import { SupportPage } from "@/components/SupportPage";
import type { HistoryItem } from "@/components/FetchHistory";
import { useDownload } from "@/hooks/useDownload";
import { useMetadata } from "@/hooks/useMetadata";
import { useLyrics } from "@/hooks/useLyrics";
import { useCover } from "@/hooks/useCover";
import { useAvailability } from "@/hooks/useAvailability";
import { ensureApiStatusCheckStarted } from "@/lib/api-status";
import { ensureSpotiFLACNextStatusCheckStarted } from "@/lib/api-status";
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
import { buildPlaylistFolderName } from "@/lib/playlist";
@@ -137,12 +133,6 @@ function App() {
const [currentListPage, setCurrentListPage] = useState(1);
const [hasUpdate, setHasUpdate] = useState(false);
const [releaseDate, setReleaseDate] = useState<string | null>(null);
const [updateInfo, setUpdateInfo] = useState<{
version: string;
changelog: string;
url: string;
} | null>(null);
const [showUpdateDialog, setShowUpdateDialog] = useState(false);
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
const [isSearchMode, setIsSearchMode] = useState(false);
const [region, setRegion] = useState(() => localStorage.getItem("spotiflac_region") || "US");
@@ -172,7 +162,7 @@ function App() {
if (savedSettings) {
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
applyFont(savedSettings.fontFamily, savedSettings.customFonts);
applyFont(savedSettings.fontFamily);
}
}, []);
useEffect(() => {
@@ -180,7 +170,7 @@ function App() {
const settings = await loadSettings();
applyThemeMode(settings.themeMode);
applyTheme(settings.theme);
applyFont(settings.fontFamily, settings.customFonts);
applyFont(settings.fontFamily);
if (!settings.downloadPath) {
const settingsWithDefaults = await getSettingsWithDefaults();
await saveSettings(settingsWithDefaults);
@@ -208,7 +198,7 @@ function App() {
};
mediaQuery.addEventListener("change", handleChange);
checkForUpdates();
ensureApiStatusCheckStarted();
ensureSpotiFLACNextStatusCheckStarted();
void loadHistory();
return () => {
mediaQuery.removeEventListener("change", handleChange);
@@ -247,24 +237,14 @@ function App() {
}, [metadata.metadata]);
const checkForUpdates = async () => {
try {
const response = await fetch("https://api.github.com/repos/spotbye/SpotiFLAC/releases/latest");
const response = await fetch("https://api.github.com/repos/afkarxyz/SpotiFLAC/releases/latest");
const data = await response.json();
const rawTag = data.tag_name || "";
const latestVersion = rawTag.replace(/^v/, "") || "";
const latestVersion = data.tag_name?.replace(/^v/, "") || "";
if (data.published_at) {
setReleaseDate(data.published_at);
}
if (latestVersion && latestVersion > CURRENT_VERSION) {
setHasUpdate(true);
setUpdateInfo({
version: latestVersion,
changelog: extractMarkdownSection(data.body || "", "Changelog"),
url: `https://github.com/spotbye/SpotiFLAC/releases/tag/${rawTag}`,
});
const dismissedVersion = localStorage.getItem("spotiflac_update_dismissed_version");
if (dismissedVersion !== latestVersion) {
setShowUpdateDialog(true);
}
}
}
catch (err) {
@@ -382,7 +362,6 @@ function App() {
name: track.name,
artist: track.artists,
image: track.images,
is_explicit: track.is_explicit,
};
}
else if ("album_info" in metadata.metadata) {
@@ -393,7 +372,6 @@ function App() {
name: album_info.name,
artist: `${album_info.total_tracks.toLocaleString()} tracks`,
image: album_info.images,
is_explicit: album_info.is_explicit,
};
}
else if ("playlist_info" in metadata.metadata) {
@@ -468,7 +446,7 @@ function App() {
}
if ("album_info" in metadata.metadata) {
const { album_info, track_list } = metadata.metadata;
return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} 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";
setSpotifyUrl(pendingArtistUrl);
const artistUrl = await metadata.handleArtistClick(artist);
@@ -486,7 +464,7 @@ function App() {
const { playlist_info, track_list } = metadata.metadata;
const settings = getSettings();
const playlistFolderName = buildPlaylistFolderName(playlist_info.owner.name, playlist_info.owner.display_name, settings.playlistOwnerFolderName);
return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} 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";
setSpotifyUrl(pendingArtistUrl);
const artistUrl = await metadata.handleArtistClick(artist);
@@ -502,7 +480,7 @@ function App() {
}
if ("artist_info" in metadata.metadata) {
const { artist_info, album_list, track_list } = metadata.metadata;
return (<ArtistInfo artistInfo={artist_info} albumList={album_list} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} 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";
setSpotifyUrl(pendingArtistUrl);
const artistUrl = await metadata.handleArtistClick(artist);
@@ -534,7 +512,7 @@ function App() {
const savedSettings = getSettings();
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
applyFont(savedSettings.fontFamily, savedSettings.customFonts);
applyFont(savedSettings.fontFamily);
if (pendingPageChange) {
setCurrentPage(pendingPageChange);
setPendingPageChange(null);
@@ -550,10 +528,8 @@ function App() {
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn}/>;
case "debug":
return <DebugLoggerPage />;
case "projects":
return <OtherProjects />;
case "support":
return <SupportPage />;
case "about":
return <AboutPage />;
case "history":
return <HistoryPage onHistorySelect={(cachedData) => {
metadata.loadFromCache(cachedData);
@@ -567,8 +543,6 @@ function App() {
return <AudioResamplerPage />;
case "file-manager":
return <FileManagerPage />;
case "lyrics-manager":
return <LyricsManagerPage />;
default:
return (<>
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} releaseDate={releaseDate}/>
@@ -577,7 +551,7 @@ function App() {
<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">
<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"/>
@@ -649,45 +623,8 @@ function App() {
</Button>)}
<Dialog open={showUpdateDialog} onOpenChange={setShowUpdateDialog}>
<DialogContent className="sm:max-w-125 [&>button]:hidden">
<DialogHeader>
<DialogTitle>Update Available</DialogTitle>
<DialogDescription>
A new version{updateInfo ? ` (v${updateInfo.version})` : ""} is available. You're on v{CURRENT_VERSION}.
</DialogDescription>
</DialogHeader>
{updateInfo?.changelog ? (<div className="max-h-72 overflow-y-auto rounded-md border bg-muted/40 p-3 custom-scrollbar">
<MarkdownLite content={updateInfo.changelog}/>
</div>) : (<p className="text-sm text-muted-foreground">No changelog provided for this release.</p>)}
<DialogFooter className="gap-2 sm:justify-between">
<Button variant="ghost" onClick={() => {
if (updateInfo) {
localStorage.setItem("spotiflac_update_dismissed_version", updateInfo.version);
}
setShowUpdateDialog(false);
}}>
Don't Show
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setShowUpdateDialog(false)}>
Download Later
</Button>
<Button onClick={() => {
if (updateInfo) {
openExternal(updateInfo.url);
}
setShowUpdateDialog(false);
}}>
Download Now
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showUnsavedChangesDialog} onOpenChange={setShowUnsavedChangesDialog}>
<DialogContent className="sm:max-w-106.25 [&>button]:hidden">
<DialogContent className="sm:max-w-[425px] [&>button]:hidden">
<DialogHeader>
<DialogTitle>Unsaved Changes</DialogTitle>
<DialogDescription>
@@ -734,7 +671,7 @@ function App() {
</Dialog>
<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">
<DialogTitle className="text-lg font-bold tracking-tight">
FFmpeg Required
-6
View File
@@ -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

-11
View File
@@ -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 { 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 ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
import XIcon from "@/assets/x.webp";
import XProIcon from "@/assets/x-pro.webp";
import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
import SpotiFLACNextIcon from "@/assets/icons/next.svg";
import KofiLogo from "@/assets/ko-fi.gif";
import KofiSvg from "@/assets/kofi_symbol.svg";
import UsdtBarcode from "@/assets/usdt.jpg";
import { langColors } from "@/assets/github-lang-colors";
const browserExtensionItems = [
{ icon: AudioTTSProIcon, label: "AudioTTS Pro", alt: "AudioTTS Pro" },
{ icon: ChatGPTTTSIcon, label: "ChatGPT TTS", alt: "ChatGPT TTS" },
{ icon: XIcon, label: "Twitter/X Media Batch Downloader", alt: "Twitter/X Media Batch Downloader" },
{ icon: XProIcon, label: "Twitter/X Media Batch Downloader Pro", alt: "Twitter/X Media Batch Downloader Pro" },
];
const projectCardClass = "cursor-pointer gap-3 py-5 transition-colors hover:bg-muted/50 dark:hover:bg-accent/50";
const projectCardHeaderClass = "px-5 gap-1.5";
@@ -20,8 +26,10 @@ const projectCardContentClass = "px-5";
const projectBodyClass = "text-[13px] leading-snug";
const releaseMetaClass = "text-xs text-muted-foreground whitespace-nowrap";
const releaseVersionClass = "text-xs bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold whitespace-nowrap";
export function OtherProjects() {
export function AboutPage() {
const [activeTab, setActiveTab] = useState<"projects" | "support">("projects");
const [repoStats, setRepoStats] = useState<Record<string, any>>({});
const [copiedUsdt, setCopiedUsdt] = useState(false);
useEffect(() => {
const fetchRepoStats = async () => {
const CACHE_KEY = "github_repo_stats_v4";
@@ -173,10 +181,24 @@ export function OtherProjects() {
};
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">Other Projects</h2>
<h2 className="text-2xl font-bold tracking-tight">About</h2>
</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">
<Card className={projectCardClass} onClick={() => openExternal("https://github.com/spotbye/SpotiFLAC-Next")}>
<CardHeader className={projectCardHeaderClass}>
@@ -201,9 +223,9 @@ export function OtherProjects() {
{repoStats["SpotiFLAC-Next"] && (<CardContent className={`${projectCardContentClass} space-y-2`}>
{repoStats["SpotiFLAC-Next"].languages?.length > 0 && (<div className="flex flex-wrap gap-2 text-xs">
{repoStats["SpotiFLAC-Next"].languages.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
backgroundColor: getLangColor(lang) + "20",
color: getLangColor(lang),
}}>
backgroundColor: getLangColor(lang) + "20",
color: getLangColor(lang),
}}>
{lang}
</span>))}
</div>)}
@@ -227,7 +249,7 @@ export function OtherProjects() {
Note
</div>
<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. Its not a paid product, but its 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>
</div>
</CardContent>)}
@@ -255,9 +277,9 @@ export function OtherProjects() {
{repoStats["Twitter-X-Media-Batch-Downloader"] && (<CardContent className={`${projectCardContentClass} space-y-2`}>
<div className="flex flex-wrap gap-2 text-xs">
{repoStats["Twitter-X-Media-Batch-Downloader"].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
backgroundColor: getLangColor(lang) + "20",
color: getLangColor(lang),
}}>
backgroundColor: getLangColor(lang) + "20",
color: getLangColor(lang),
}}>
{lang}
</span>))}
</div>
@@ -273,30 +295,30 @@ export function OtherProjects() {
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5"/>{" "}
{formatTimeAgo(repoStats["Twitter-X-Media-Batch-Downloader"]
.createdAt)}
.createdAt)}
</span>
</div>
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
<span className="flex items-center gap-1">
<Download className="h-3.5 w-3.5"/> TOTAL:{" "}
{formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"]
.totalDownloads)}
.totalDownloads)}
</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<Download className="h-3.5 w-3.5"/> LATEST:{" "}
{formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"]
.latestDownloads)}
.latestDownloads)}
</span>
</div>
</CardContent>)}
</Card>
<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}>
<CardTitle className="leading-tight">Browser Extensions & Scripts</CardTitle>
<CardDescription className="flex flex-col gap-2.5 pt-1.5">
{browserExtensionItems.map((item) => (<div key={item.alt} className="flex items-center gap-2.5">
<img src={item.icon} className="h-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`}>
{item.label}
</span>
@@ -317,6 +339,55 @@ export function OtherProjects() {
</Card>
</div>
</div>
</div>)}
{activeTab === "support" && (<div className="flex flex-col items-center justify-center p-4 space-y-6">
<div className="flex flex-col md:flex-row w-full max-w-3xl bg-card rounded-xl border shadow-sm">
<div className="flex-1 p-6 flex flex-col items-center justify-between border-b md:border-b-0 md:border-r space-y-6">
<div className="flex flex-col items-center space-y-4">
<div className="h-32 flex items-center justify-center w-full relative">
<img src={KofiLogo} className="w-72 absolute pointer-events-none" alt="Ko-fi"/>
</div>
<h4 className="font-semibold text-foreground">Support via Ko-fi</h4>
<p className="text-sm text-muted-foreground text-center px-4">
Enjoying the project? You can support ongoing development by buying me a coffee.
</p>
</div>
<Button className="h-10 w-full text-sm font-semibold text-white gap-2 group bg-[#72a4f2] hover:bg-[#5f8cd6]" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
<img src={KofiSvg} className="w-5 h-5 shrink-0" alt="" aria-hidden="true"/>
Support me on Ko-fi
</Button>
</div>
<div className="flex-1 p-6 flex flex-col items-center justify-between space-y-6">
<div className="flex flex-col items-center space-y-4 w-full">
<div className="h-32 flex items-center justify-center">
<div className="p-2 bg-white rounded-xl shadow-sm border">
<img src={UsdtBarcode} className="w-24 h-24 object-contain" alt="USDT Barcode"/>
</div>
</div>
<h4 className="font-semibold text-foreground">USDT (TRC20)</h4>
<p className="text-sm text-muted-foreground text-center px-4">
Crypto donations are also accepted. Scan the QR code or copy the address.
</p>
</div>
<div className="flex items-center gap-2 bg-muted/50 pl-3 pr-1.5 py-1.5 rounded-lg border w-full justify-between h-10">
<code className="text-xs font-mono text-muted-foreground truncate" title="THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs">
THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs
</code>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 hover:bg-background" onClick={() => {
navigator.clipboard.writeText("THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs");
setCopiedUsdt(true);
setTimeout(() => setCopiedUsdt(false), 500);
}}>
{copiedUsdt ? <CircleCheck className="h-3.5 w-3.5 text-green-500"/> : <Copy className="h-3.5 w-3.5"/>}
</Button>
</div>
</div>
</div>
</div>)}
</div>
</div>);
}
+7 -12
View File
@@ -12,7 +12,7 @@ import { useState } from "react";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath } from "@/lib/utils";
import { parseTemplate, type TemplateData } from "@/lib/settings";
import { buildClickableArtists, splitArtistNames, getClickableArtistKey } from "@/lib/artist-links";
import { buildClickableArtists, splitArtistNames } from "@/lib/artist-links";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
interface AlbumInfoProps {
albumInfo: {
@@ -21,7 +21,6 @@ interface AlbumInfoProps {
images: string;
release_date: string;
total_tracks: number;
is_explicit?: boolean;
artist_id?: string;
artist_url?: string;
};
@@ -36,7 +35,6 @@ interface AlbumInfoProps {
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
downloadRemainingCount: number;
currentDownloadInfo: {
name: string;
artists: string;
@@ -79,7 +77,7 @@ interface AlbumInfoProps {
onTrackClick?: (track: TrackMetadata) => 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 albumArtistNames = splitArtistNames(albumInfo.artists);
const artistSeparator = albumInfo.artists.includes(";") ? "; " : ", ";
@@ -207,21 +205,18 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
</div>)}
<div className="flex-1 space-y-4">
<div className="space-y-2">
<p className="text-sm font-medium flex items-center gap-2">
{albumInfo.is_explicit && (<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-red-600 text-[10px] text-white" title="Explicit">E</span>)}
<span>Album</span>
</p>
<p className="text-sm font-medium">Album</p>
<h2 className="text-4xl font-bold">{albumInfo.name}</h2>
<div className="flex items-center gap-2 text-sm">
<span className="font-medium">
{clickableAlbumArtists.length > 0 ? clickableAlbumArtists.map((artist, index) => (<span key={getClickableArtistKey(artist)}>
{onArtistClick && artist.external_urls ? (<button type="button" className="cursor-pointer rounded-sm bg-transparent p-0 text-inherit hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60" onClick={() => onArtistClick({
{clickableAlbumArtists.length > 0 ? clickableAlbumArtists.map((artist, index) => (<span key={`${artist.id || artist.name}-${index}`}>
{onArtistClick && artist.external_urls ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
id: artist.id,
name: artist.name,
external_urls: artist.external_urls,
})}>
{artist.name}
</button>) : (artist.name)}
</span>) : (artist.name)}
{index < clickableAlbumArtists.length - 1 && artistSeparator}
</span>)) : albumInfo.artists}
</span>
@@ -275,7 +270,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
</TooltipContent>
</Tooltip>)}
</div>
{isDownloading && (<DownloadProgress progress={downloadProgress} remainingCount={downloadRemainingCount} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
</div>
</div>
</CardContent>
+17 -29
View File
@@ -1,15 +1,14 @@
import { Button } from "@/components/ui/button";
import { PlugZap, CheckCircle2, Loader2, Wrench, Server } from "lucide-react";
import { TidalIcon, QobuzIcon, AmazonIcon, AppleMusicIcon, DeezerIcon } from "./PlatformIcons";
import { SearchCheck, CheckCircle2, XCircle, Loader2 } from "lucide-react";
import { TidalIcon, QobuzIcon, AmazonIcon, MusicBrainzIcon, AppleMusicIcon, DeezerIcon } from "./PlatformIcons";
import { useApiStatus } from "@/hooks/useApiStatus";
import { SPOTIFLAC_NEXT_SOURCES } from "@/lib/api-status";
import { openExternal } from "@/lib/utils";
function renderStatusIndicator(status: "checking" | "online" | "offline" | "idle") {
function renderStatusIcon(status: "checking" | "online" | "offline" | "idle") {
if (status === "online") {
return <CheckCircle2 className="h-5 w-5 text-emerald-500"/>;
}
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;
}
@@ -20,6 +19,9 @@ function renderPlatformIcon(type: string) {
if (type === "amazon") {
return <AmazonIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
}
if (type === "musicbrainz") {
return <MusicBrainzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
}
if (type === "deezer") {
return <DeezerIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
}
@@ -29,41 +31,27 @@ function renderPlatformIcon(type: string) {
return <QobuzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
}
export function ApiStatusTab() {
const { sources, statuses, nextStatuses, checkingSources, checkAllCurrent, checkAllNext } = useApiStatus();
const isCheckingCurrent = sources.some((source) => checkingSources[source.id] === true);
const isCheckingNext = SPOTIFLAC_NEXT_SOURCES.some((source) => nextStatuses[source.id] === "checking");
const isChecking = isCheckingCurrent || isCheckingNext;
const checkAll = () => {
void checkAllCurrent();
void checkAllNext();
};
const { sources, statuses, nextStatuses, checkingSources, checkOne } = useApiStatus();
return (<div className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC</h3>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => openExternal("https://spotbye.qzz.io")} className="gap-2">
<Server className="h-4 w-4"/>
Details
</Button>
<Button variant="outline" size="sm" onClick={checkAll} disabled={isChecking} className="gap-2">
{isChecking ? <Loader2 className="h-4 w-4 animate-spin"/> : <PlugZap className="h-4 w-4"/>}
Check
</Button>
</div>
</div>
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Services</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{sources.map((source) => {
const status = statuses[source.id] || "idle";
const isChecking = checkingSources[source.id] === true;
return (<div key={source.id} className="space-y-4 p-4 border rounded-lg bg-card text-card-foreground shadow-sm">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
{renderPlatformIcon(source.type)}
<p className="font-medium leading-none">{source.name}</p>
</div>
<div className="flex items-center">{renderStatusIndicator(status)}</div>
<div className="flex items-center">{renderStatusIcon(status)}</div>
</div>
<Button variant="outline" size="sm" onClick={() => void checkOne(source.id)} disabled={isChecking} className="w-full gap-2">
{isChecking ? <Loader2 className="h-4 w-4 animate-spin"/> : <SearchCheck className="h-4 w-4"/>}
Check
</Button>
</div>);
})}
</div>
@@ -72,7 +60,7 @@ export function ApiStatusTab() {
<div className="border-t"/>
<div className="space-y-4">
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Next</h3>
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Next Services</h3>
<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) => {
@@ -82,7 +70,7 @@ export function ApiStatusTab() {
{renderPlatformIcon(source.id)}
<p className="font-medium leading-none">{source.name}</p>
</div>
<div className="flex items-center">{renderStatusIndicator(status)}</div>
<div className="flex items-center">{renderStatusIcon(status)}</div>
</div>);
})}
</div>
+6 -11
View File
@@ -36,7 +36,6 @@ interface ArtistInfoProps {
album_type: string;
external_urls: string;
total_tracks?: number;
is_explicit?: boolean;
}>;
trackList: TrackMetadata[];
searchQuery: string;
@@ -49,7 +48,6 @@ interface ArtistInfoProps {
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
downloadRemainingCount: number;
currentDownloadInfo: {
name: string;
artists: string;
@@ -97,7 +95,7 @@ interface ArtistInfoProps {
onTrackClick?: (track: TrackMetadata) => void;
onBack?: () => void;
}
export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, 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 [downloadingAvatar, setDownloadingAvatar] = useState(false);
const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null);
@@ -327,7 +325,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
{artistInfo.header ? (<>
<div className="relative w-full h-64 bg-cover bg-center">
<div className="absolute inset-0 bg-cover bg-center" style={{ backgroundImage: `url(${artistInfo.header})` }}/>
<div className="absolute inset-0 bg-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">
<Button variant="ghost" size="icon" onClick={onBack} className="text-white hover:bg-white/20 hover:text-white">
<XCircle className="h-5 w-5"/>
@@ -476,7 +474,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
</Tooltip>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{artistInfo.gallery!.map((imageUrl, index) => (<div key={`${imageUrl}-${index}`} className="relative group">
{artistInfo.gallery!.map((imageUrl, index) => (<div key={index} className="relative group">
<div className="relative aspect-square rounded-md overflow-hidden shadow-md">
<img src={imageUrl} alt={`${artistInfo.name} gallery ${index + 1}`} className="w-full h-full object-cover"/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors flex items-center justify-center">
@@ -538,10 +536,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
</span>
</div>
</div>
<h4 className="font-semibold truncate text-sm flex items-center gap-2">
{album.is_explicit && (<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-red-600 text-[10px] text-white" title="Explicit">E</span>)}
<span className="truncate">{album.name}</span>
</h4>
<h4 className="font-semibold truncate text-sm">{album.name}</h4>
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-1">
<span>{album.release_date?.split("-")[0]}</span>
{album.total_tracks && (<>
@@ -568,7 +563,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
Filter Albums
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-125 h-[80vh] flex flex-col">
<DialogContent className="sm:max-w-[500px] h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Select Albums</DialogTitle>
</DialogHeader>
@@ -639,7 +634,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
</Tooltip>)}
</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}/>
<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>)}
+9 -18
View File
@@ -51,12 +51,12 @@ export function AudioConverterPage() {
}
return [];
});
const [outputFormat, setOutputFormat] = useState<"mp3" | "m4a" | "wav" | "aiff" | "opus">(() => {
const [outputFormat, setOutputFormat] = useState<"mp3" | "m4a">(() => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (["mp3", "m4a", "wav", "aiff", "opus"].includes(parsed.outputFormat)) {
if (parsed.outputFormat === "mp3" || parsed.outputFormat === "m4a") {
return parsed.outputFormat;
}
}
@@ -98,7 +98,7 @@ export function AudioConverterPage() {
const [isFullscreen, setIsFullscreen] = useState(false);
const saveState = useCallback((stateToSave: {
files: AudioFile[];
outputFormat: "mp3" | "m4a" | "wav" | "aiff" | "opus";
outputFormat: "mp3" | "m4a";
bitrate: string;
m4aCodec: "aac" | "alac";
}) => {
@@ -116,7 +116,7 @@ export function AudioConverterPage() {
if (files.length === 0)
return;
const allMP3 = files.every((f) => f.format === "mp3");
if (allMP3 && outputFormat === "mp3") {
if (allMP3 && outputFormat !== "m4a") {
setOutputFormat("m4a");
}
const hasFlac = files.some((f) => f.format === "flac");
@@ -375,24 +375,15 @@ export function AudioConverterPage() {
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">Format:</Label>
<ToggleGroup type="single" variant="outline" value={outputFormat} onValueChange={(value) => {
if (value)
setOutputFormat(value as "mp3" | "m4a" | "wav" | "aiff" | "opus");
}}>
if (value && !isFormatDisabled)
setOutputFormat(value as "mp3" | "m4a");
}} disabled={isFormatDisabled}>
{!isFormatDisabled && (<ToggleGroupItem value="mp3" aria-label="MP3">
MP3
</ToggleGroupItem>)}
<ToggleGroupItem value="m4a" aria-label="M4A">
<ToggleGroupItem value="m4a" aria-label="M4A" disabled={isFormatDisabled}>
M4A
</ToggleGroupItem>
<ToggleGroupItem value="opus" aria-label="Opus">
Opus
</ToggleGroupItem>
<ToggleGroupItem value="wav" aria-label="WAV">
WAV
</ToggleGroupItem>
<ToggleGroupItem value="aiff" aria-label="AIFF">
AIFF
</ToggleGroupItem>
</ToggleGroup>
</div>
@@ -408,7 +399,7 @@ export function AudioConverterPage() {
</ToggleGroup>
</div>)}
{(outputFormat === "mp3" || outputFormat === "opus" || (outputFormat === "m4a" && m4aCodec === "aac")) && (<div className="flex items-center gap-2">
{!(outputFormat === "m4a" && m4aCodec === "alac") && (<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">Bitrate:</Label>
<ToggleGroup type="single" variant="outline" value={bitrate} onValueChange={(value) => {
if (value)
+7 -17
View File
@@ -1,23 +1,16 @@
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { StopCircle, Clock } from "lucide-react";
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
import { StopCircle } from "lucide-react";
interface DownloadProgressProps {
progress: number;
remainingCount?: number;
currentTrack: {
name: string;
artists: string;
} | null;
onStop: () => void;
}
export function DownloadProgress({ progress, remainingCount = 0, currentTrack, onStop }: DownloadProgressProps) {
const liveProgress = useDownloadProgress();
const isRateLimited = Boolean(liveProgress.rate_limited) && (liveProgress.rate_limit_secs ?? 0) > 0;
const rateLimitSecs = liveProgress.rate_limit_secs ?? 0;
export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) {
const clampedProgress = Math.min(100, Math.max(0, progress));
const safeRemainingCount = Math.max(0, remainingCount);
const remainingLabel = `${safeRemainingCount.toLocaleString()} ${safeRemainingCount === 1 ? "track" : "tracks"} left`;
return (<div className="w-full space-y-2 mt-4">
<div className="flex items-center gap-2">
<Progress value={clampedProgress} className="h-2 flex-1"/>
@@ -26,14 +19,11 @@ export function DownloadProgress({ progress, remainingCount = 0, currentTrack, o
Stop
</Button>
</div>
{isRateLimited ? (<p className="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
<Clock className="h-3.5 w-3.5 shrink-0"/>
Rate limited, please wait. Retrying in {rateLimitSecs}s...
</p>) : (<p className="text-xs text-muted-foreground">
{clampedProgress}% {remainingLabel} -{" "}
<p className="text-xs text-muted-foreground">
{clampedProgress}% -{" "}
{currentTrack
? `${currentTrack.name} - ${currentTrack.artists}`
: "Preparing download..."}
</p>)}
? `${currentTrack.name} - ${currentTrack.artists}`
: "Preparing download..."}
</p>
</div>);
}
+3 -7
View File
@@ -6,7 +6,6 @@ export interface HistoryItem {
name: string;
artist: string;
image: string;
is_explicit?: boolean;
timestamp: number;
}
interface FetchHistoryProps {
@@ -76,12 +75,9 @@ export function FetchHistory({ history, onSelect, onRemove }: FetchHistoryProps)
</div>)}
</div>
<div className="space-y-0.5">
<div className="flex items-center gap-1 min-w-0">
{item.is_explicit ? <span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded bg-red-600 text-[9px] font-bold text-white" title="Explicit">E</span> : null}
<p className="min-w-0 text-xs font-medium truncate" title={item.name}>
{item.name}
</p>
</div>
<p className="text-xs font-medium truncate" title={item.name}>
{item.name}
</p>
<p className="text-xs text-muted-foreground truncate" title={item.artist}>
{item.artist}
</p>
+3 -7
View File
@@ -11,13 +11,9 @@ export function Header({ version, hasUpdate, releaseDate }: HeaderProps) {
return (<div className="relative">
<div className="text-center space-y-2">
<div className="flex items-center justify-center gap-3">
<button type="button" className="cursor-pointer rounded-sm border-0 bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60" onClick={() => window.location.reload()} aria-label="Reload SpotiFLAC">
<img src="/icon.svg" alt="" className="w-12 h-12"/>
</button>
<h1 className="text-4xl font-bold">
<button type="button" className="cursor-pointer rounded-sm border-0 bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60" onClick={() => window.location.reload()}>
SpotiFLAC
</button>
<img src="/icon.svg" alt="SpotiFLAC" className="w-12 h-12 cursor-pointer" onClick={() => window.location.reload()}/>
<h1 className="text-4xl font-bold cursor-pointer" onClick={() => window.location.reload()}>
SpotiFLAC
</h1>
<div className="relative">
<Tooltip>
+16 -66
View File
@@ -9,8 +9,7 @@ import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, Pagi
import { GetDownloadHistory, ClearDownloadHistory, GetPreviewURL, GetFetchHistory, DeleteDownloadHistoryItem, DeleteFetchHistoryItem, ClearFetchHistoryByType } from "../../wailsjs/go/main/App";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { openExternal } from "@/lib/utils";
import { getPreviewVolume } from "@/lib/preview";
import { createPreviewPlayback, type PreviewPlayback } from "@/lib/preview-player";
import { SPOTIFY_PREVIEW_VOLUME } from "@/lib/preview";
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
const formatDate = (timestamp: number) => {
const date = new Date(timestamp * 1000);
@@ -22,37 +21,6 @@ const formatDate = (timestamp: number) => {
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
const getHistoryFormatLabel = (item: DownloadHistoryItem) => {
const normalizedPath = (item.path || "").trim().toLowerCase();
if (normalizedPath.endsWith(".flac"))
return "FLAC";
if (normalizedPath.endsWith(".mp3"))
return "MP3";
if (normalizedPath.endsWith(".m4a"))
return "M4A";
const normalizedFormat = (item.format || "").trim().toLowerCase();
switch (normalizedFormat) {
case "hi_res":
case "hi_res_lossless":
case "lossless":
case "flac":
case "6":
case "7":
case "27":
return "FLAC";
case "alac":
case "apple":
case "atmos":
case "m4a":
case "m4a-aac":
case "m4a-alac":
return "M4A";
case "mp3":
return "MP3";
default:
return (item.format || "-").toUpperCase();
}
};
interface DownloadHistoryItem {
id: string;
spotify_id: string;
@@ -75,7 +43,6 @@ interface FetchHistoryItem {
info: string;
image: string;
data: string;
is_explicit?: boolean;
timestamp: number;
}
interface HistoryPageProps {
@@ -90,7 +57,7 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
const [downloadSortBy, setDownloadSortBy] = useState("default");
const [downloadCurrentPage, setDownloadCurrentPage] = useState(1);
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 [filteredFetchHistory, setFilteredFetchHistory] = useState<FetchHistoryItem[]>([]);
const [activeFetchTab, setActiveFetchTab] = useState("track");
@@ -155,8 +122,9 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
}, [activeTab]);
useEffect(() => {
return () => {
playbackRef.current?.destroy();
playbackRef.current = null;
if (audioRef.current) {
audioRef.current.pause();
}
};
}, []);
useEffect(() => {
@@ -212,35 +180,20 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
}, [fetchSearchQuery, activeFetchTab]);
const handlePreview = async (id: string, spotifyId: string) => {
if (playingPreviewId === id) {
playbackRef.current?.destroy();
playbackRef.current = null;
audioRef.current?.pause();
setPlayingPreviewId(null);
return;
}
if (playbackRef.current) {
playbackRef.current.destroy();
playbackRef.current = null;
if (audioRef.current) {
audioRef.current.pause();
}
try {
const url = await GetPreviewURL(spotifyId);
if (url) {
const playback = await createPreviewPlayback(url, getPreviewVolume());
const audio = playback.audio;
playbackRef.current = playback;
audio.onended = () => {
setPlayingPreviewId(null);
if (playbackRef.current?.audio === audio) {
playbackRef.current.destroy();
playbackRef.current = null;
}
};
audio.onerror = () => {
setPlayingPreviewId(null);
if (playbackRef.current?.audio === audio) {
playbackRef.current.destroy();
playbackRef.current = null;
}
};
const audio = new Audio(url);
audioRef.current = audio;
audio.volume = SPOTIFY_PREVIEW_VOLUME;
audio.onended = () => setPlayingPreviewId(null);
audio.play();
setPlayingPreviewId(id);
}
@@ -318,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"/>
</div>
<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"/>
<SelectValue placeholder="Sort by"/>
</SelectTrigger>
@@ -376,10 +329,10 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
<div className="truncate">{item.album}</div>
</td>
<td className="p-3 align-middle text-left hidden lg:table-cell">
<td className="p-3 align-middle text-left hidden lg:table-cell">
<div className="flex flex-col items-start gap-1">
<span className="text-xs font-bold text-foreground">
{getHistoryFormatLabel(item)}
{['HI_RES_LOSSLESS', 'LOSSLESS', 'flac', '6', '7', '27'].includes(item.format?.toLowerCase() || '') ? 'FLAC' : item.format?.toUpperCase()}
</span>
{item.quality && <span className="text-[11px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>}
</div>
@@ -567,10 +520,7 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
{item.type.slice(0, 2).toUpperCase()}
</div>)}
</div>
<span className="font-medium text-sm truncate flex items-center gap-2">
{item.is_explicit && (<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-red-600 text-[10px] text-white" title="Explicit">E</span>)}
<span className="truncate">{item.name}</span>
</span>
<span className="font-medium text-sm truncate">{item.name}</span>
</div>
</td>
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
@@ -1,327 +0,0 @@
import { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Upload, X, FileText, Trash2, AlertCircle, Music, Clock, Download } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { ReadEmbeddedLyrics, SelectLyricsFiles, ExtractLyricsToLRC } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
interface LyricsFile {
path: string;
name: string;
format: string;
lyrics: string;
source: string;
synced: boolean;
status: "loading" | "loaded" | "empty" | "error";
error?: string;
}
const SUPPORTED_EXTENSIONS = [".lrc", ".txt", ".flac", ".mp3", ".m4a", ".aac", ".opus", ".ogg"];
function getExtension(path: string): string {
const lower = path.toLowerCase();
const dot = lower.lastIndexOf(".");
return dot >= 0 ? lower.slice(dot) : "";
}
export function LyricsManagerPage() {
const [files, setFiles] = useState<LyricsFile[]>([]);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [extracting, setExtracting] = useState(false);
useEffect(() => {
const checkFullscreen = () => {
setIsFullscreen(window.innerHeight >= window.screen.height * 0.9);
};
checkFullscreen();
window.addEventListener("resize", checkFullscreen);
window.addEventListener("focus", checkFullscreen);
return () => {
window.removeEventListener("resize", checkFullscreen);
window.removeEventListener("focus", checkFullscreen);
};
}, []);
const addFiles = useCallback(async (paths: string[]) => {
const validPaths = paths.filter((path) => SUPPORTED_EXTENSIONS.includes(getExtension(path)));
if (validPaths.length === 0) {
if (paths.length > 0) {
toast.error("Unsupported files", {
description: "Only LRC and audio files (FLAC, MP3, M4A) are supported.",
});
}
return;
}
const newPaths: string[] = [];
setFiles((prev) => {
const toAdd = validPaths.filter((path) => !prev.some((f) => f.path === path));
newPaths.push(...toAdd);
const entries: LyricsFile[] = toAdd.map((path) => {
const name = path.split(/[/\\]/).pop() || path;
return {
path,
name,
format: getExtension(path).slice(1),
lyrics: "",
source: "",
synced: false,
status: "loading" as const,
};
});
if (entries.length === 0) {
return prev;
}
return [...prev, ...entries];
});
for (const path of newPaths) {
try {
const result = await ReadEmbeddedLyrics(path);
setFiles((prev) => prev.map((f) => {
if (f.path !== path)
return f;
if (result.error) {
return { ...f, status: "empty" as const, error: result.error };
}
return {
...f,
lyrics: result.lyrics,
source: result.source,
synced: result.synced,
status: "loaded" as const,
};
}));
}
catch (err) {
setFiles((prev) => prev.map((f) => f.path === path
? { ...f, status: "error" as const, error: err instanceof Error ? err.message : "Failed to read lyrics" }
: f));
}
}
setSelectedPath((prev) => prev ?? newPaths[0] ?? null);
}, []);
const handleSelectFiles = async () => {
try {
const selected = await SelectLyricsFiles();
if (selected && selected.length > 0) {
addFiles(selected);
}
}
catch (err) {
toast.error("File Selection Failed", {
description: err instanceof Error ? err.message : "Failed to select files",
});
}
};
const handleFileDrop = useCallback((_x: number, _y: number, paths: string[]) => {
setIsDragging(false);
if (paths.length === 0)
return;
addFiles(paths);
}, [addFiles]);
useEffect(() => {
OnFileDrop((x, y, paths) => {
handleFileDrop(x, y, paths);
}, true);
return () => {
OnFileDropOff();
};
}, [handleFileDrop]);
const removeFile = (path: string) => {
setFiles((prev) => {
const next = prev.filter((f) => f.path !== path);
setSelectedPath((current) => {
if (current !== path)
return current;
return next[0]?.path ?? null;
});
return next;
});
};
const clearFiles = () => {
setFiles([]);
setSelectedPath(null);
};
const selectedFile = files.find((f) => f.path === selectedPath) || null;
const extractFile = async (file: LyricsFile, overwrite: boolean) => {
const result = await ExtractLyricsToLRC(file.path, overwrite);
if (result.success) {
return { ok: true as const, output: result.output_path };
}
if (result.already_exists) {
return { ok: false as const, alreadyExists: true, output: result.output_path };
}
return { ok: false as const, error: result.error || "Failed to extract lyrics" };
};
const handleExtractSelected = async () => {
if (!selectedFile || selectedFile.status !== "loaded")
return;
setExtracting(true);
try {
const result = await extractFile(selectedFile, false);
if (result.ok) {
toast.success("Lyrics extracted", { description: result.output });
}
else if (result.alreadyExists) {
toast.info("LRC already exists", {
description: "A .lrc file with the same name already exists next to this file.",
});
}
else {
toast.error("Extract failed", { description: result.error });
}
}
catch (err) {
toast.error("Extract failed", {
description: err instanceof Error ? err.message : "Unknown error",
});
}
finally {
setExtracting(false);
}
};
const handleExtractAll = async () => {
const extractable = files.filter((f) => f.status === "loaded");
if (extractable.length === 0) {
toast.error("Nothing to extract", {
description: "No files with embedded lyrics are loaded.",
});
return;
}
setExtracting(true);
let success = 0;
let skipped = 0;
let failed = 0;
for (const file of extractable) {
try {
const result = await extractFile(file, false);
if (result.ok)
success++;
else if (result.alreadyExists)
skipped++;
else
failed++;
}
catch {
failed++;
}
}
setExtracting(false);
if (success > 0) {
toast.success("Lyrics extracted", {
description: `${success} file(s) extracted${skipped > 0 ? `, ${skipped} skipped` : ""}${failed > 0 ? `, ${failed} failed` : ""}`,
});
}
else if (skipped > 0 && failed === 0) {
toast.info("Already extracted", {
description: `${skipped} .lrc file(s) already exist.`,
});
}
else {
toast.error("Extract failed", {
description: `${failed} file(s) failed to extract.`,
});
}
};
const embeddedLoadedCount = files.filter((f) => f.status === "loaded" && f.source === "embedded").length;
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Lyrics Manager</h1>
{files.length > 0 && (<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleSelectFiles}>
<Upload className="h-4 w-4"/>
Add Files
</Button>
{embeddedLoadedCount > 0 && (<Button variant="outline" size="sm" onClick={handleExtractAll} disabled={extracting}>
{extracting ? <Spinner className="h-4 w-4"/> : <Download className="h-4 w-4"/>}
Extract All
</Button>)}
<Button variant="outline" size="sm" onClick={clearFiles} disabled={extracting}>
<Trash2 className="h-4 w-4"/>
Clear All
</Button>
</div>)}
</div>
<div className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${isFullscreen ? "flex-1 min-h-[400px]" : "min-h-[400px]"} ${isDragging
? "border-primary bg-primary/10"
: "border-muted-foreground/30"}`} onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}} onDragLeave={(e) => {
e.preventDefault();
setIsDragging(false);
}} onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
}} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}>
{files.length === 0 ? (<>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Upload className="h-8 w-8 text-primary"/>
</div>
<p className="text-sm text-muted-foreground mb-4 text-center">
{isDragging
? "Drop your files here"
: "Drag and drop LRC or audio files here, or click the button below to select"}
</p>
<Button onClick={handleSelectFiles} size="lg">
<Upload className="h-5 w-5"/>
Select Files
</Button>
<p className="text-xs text-muted-foreground mt-4 text-center">
Reads embedded lyrics from FLAC, MP3, M4A, Opus or plain LRC files
</p>
</>) : (<div className="w-full h-full p-4 flex flex-col md:flex-row gap-4 min-h-0">
<div className="md:w-64 shrink-0 flex flex-col gap-2 md:border-r md:pr-4 max-h-48 md:max-h-none overflow-y-auto">
{files.map((file) => {
const isActive = file.path === selectedPath;
return (<button key={file.path} onClick={() => setSelectedPath(file.path)} className={`group flex items-center gap-2 rounded-lg border p-2 text-left transition-colors ${isActive ? "border-primary bg-primary/10" : "hover:bg-muted/60"}`}>
{file.status === "loading" ? (<Spinner className="h-4 w-4 shrink-0 text-primary"/>)
: file.status === "error" || file.status === "empty" ? (<AlertCircle className="h-4 w-4 shrink-0 text-destructive"/>)
: (<FileText className="h-4 w-4 shrink-0 text-muted-foreground"/>)}
<div className="flex-1 min-w-0">
<p className="truncate text-xs font-medium">{file.name}</p>
<p className="truncate text-[10px] uppercase text-muted-foreground">{file.format}</p>
</div>
<span role="button" tabIndex={-1} onClick={(e) => { e.stopPropagation(); removeFile(file.path); }} className="opacity-0 group-hover:opacity-100 transition-opacity rounded p-1 hover:bg-muted">
<X className="h-3.5 w-3.5"/>
</span>
</button>);
})}
</div>
<div className="flex-1 min-w-0 flex flex-col min-h-0">
{!selectedFile ? (<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">
Select a file to view its lyrics
</div>) : selectedFile.status === "loading" ? (<div className="flex-1 flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Spinner className="h-4 w-4"/>
Reading lyrics...
</div>) : selectedFile.status === "error" || selectedFile.status === "empty" ? (<div className="flex-1 flex flex-col items-center justify-center gap-2 text-center px-6">
<AlertCircle className="h-8 w-8 text-destructive"/>
<p className="text-sm font-medium">{selectedFile.name}</p>
<p className="text-xs text-muted-foreground">{selectedFile.error || "No lyrics found"}</p>
</div>) : (<>
<div className="flex flex-col gap-2 pb-3 border-b shrink-0">
<div className="flex items-center gap-2 min-w-0">
<p className="truncate text-sm font-medium flex-1">{selectedFile.name}</p>
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase shrink-0">
{selectedFile.source === "lrc" ? (<><FileText className="h-3 w-3"/> LRC</>) : (<><Music className="h-3 w-3"/> Embedded</>)}
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase shrink-0">
<Clock className="h-3 w-3"/>
{selectedFile.synced ? "Synced" : "Plain"}
</span>
</div>
<div className="flex items-center gap-2">
{selectedFile.source === "embedded" && (<Button variant="outline" size="sm" onClick={handleExtractSelected} disabled={extracting}>
{extracting ? <Spinner className="h-4 w-4"/> : <Download className="h-4 w-4"/>}
Extract LRC
</Button>)}
</div>
</div>
<div className="flex-1 overflow-y-auto pt-3 min-h-0">
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed text-foreground/90">{selectedFile.lyrics}</pre>
</div>
</>)}
</div>
</div>)}
</div>
</div>);
}
-99
View File
@@ -1,99 +0,0 @@
import { Fragment, type ReactNode } from "react";
import { openExternal } from "@/lib/utils";
export function extractMarkdownSection(body: string, heading: string): string {
const text = (body || "").replace(/\r\n/g, "\n");
const lines = text.split("\n");
const target = heading.trim().toLowerCase();
let start = -1;
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(/^#{1,6}\s+(.*)$/);
if (m && m[1].trim().toLowerCase() === target) {
start = i + 1;
break;
}
}
if (start === -1) {
return text.trim();
}
const collected: string[] = [];
for (let i = start; i < lines.length; i++) {
if (/^#{1,6}\s+/.test(lines[i])) {
break;
}
collected.push(lines[i]);
}
return collected.join("\n").trim();
}
function renderInline(text: string, keyPrefix: string): ReactNode[] {
const nodes: ReactNode[] = [];
const pattern = /\[([^\]]+)\]\(([^)]+)\)|\*\*([^*]+)\*\*|\*([^*]+)\*|`([^`]+)`/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
let i = 0;
while ((match = pattern.exec(text)) !== null) {
if (match.index > lastIndex) {
nodes.push(<Fragment key={`${keyPrefix}-t${i}`}>{text.slice(lastIndex, match.index)}</Fragment>);
}
if (match[1] !== undefined && match[2] !== undefined) {
const label = match[1];
const url = match[2];
nodes.push(<button key={`${keyPrefix}-l${i}`} type="button" onClick={() => openExternal(url)} className="text-primary underline hover:opacity-80 bg-transparent border-none p-0 cursor-pointer">
{label}
</button>);
}
else if (match[3] !== undefined) {
nodes.push(<strong key={`${keyPrefix}-b${i}`} className="font-semibold text-foreground">{match[3]}</strong>);
}
else if (match[4] !== undefined) {
nodes.push(<em key={`${keyPrefix}-i${i}`}>{match[4]}</em>);
}
else if (match[5] !== undefined) {
nodes.push(<code key={`${keyPrefix}-c${i}`} className="rounded bg-muted px-1 py-0.5 font-mono text-xs">{match[5]}</code>);
}
lastIndex = pattern.lastIndex;
i++;
}
if (lastIndex < text.length) {
nodes.push(<Fragment key={`${keyPrefix}-t${i}`}>{text.slice(lastIndex)}</Fragment>);
}
return nodes;
}
export function MarkdownLite({ content }: {
content: string;
}) {
const lines = (content || "").replace(/\r\n/g, "\n").split("\n");
const blocks: ReactNode[] = [];
let listItems: string[] = [];
let key = 0;
const flushList = () => {
if (listItems.length === 0)
return;
const items = listItems;
listItems = [];
blocks.push(<ul key={`ul-${key++}`} className="list-disc space-y-1 pl-5">
{items.map((item, idx) => (<li key={idx}>{renderInline(item, `li-${key}-${idx}`)}</li>))}
</ul>);
};
for (const raw of lines) {
const line = raw.trimEnd();
const bullet = line.match(/^\s*[-*]\s+(.*)$/);
if (bullet) {
listItems.push(bullet[1]);
continue;
}
flushList();
const heading = line.match(/^(#{1,6})\s+(.*)$/);
if (heading) {
blocks.push(<p key={`h-${key++}`} className="font-semibold text-foreground">
{renderInline(heading[2], `h-${key}`)}
</p>);
continue;
}
if (line.trim() === "") {
continue;
}
blocks.push(<p key={`p-${key++}`}>{renderInline(line, `p-${key}`)}</p>);
}
flushList();
return <div className="space-y-2 text-sm text-muted-foreground">{blocks}</div>;
}
+2 -3
View File
@@ -41,7 +41,6 @@ interface PlaylistInfoProps {
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
downloadRemainingCount: number;
currentDownloadInfo: {
name: string;
artists: string;
@@ -89,7 +88,7 @@ interface PlaylistInfoProps {
onTrackClick: (track: TrackMetadata) => 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 playlistName = playlistInfo.owner.name;
const playlistFolderName = buildPlaylistFolderName(playlistName, playlistInfo.owner.display_name, settings.playlistOwnerFolderName);
@@ -236,7 +235,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
</TooltipContent>
</Tooltip>)}
</div>
{isDownloading && (<DownloadProgress progress={downloadProgress} remainingCount={downloadRemainingCount} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
</div>
</div>
</CardContent>
+5 -13
View File
@@ -604,22 +604,14 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
{!searchMode && (<>
{showRegionSelector && (<Select value={region} onValueChange={onRegionChange}>
<SelectTrigger className="w-22.5 shrink-0">
<SelectValue placeholder="Region">
<span className="flex items-center gap-1.5">
<img src={`/assets/flags/${region.toLowerCase()}.svg`} alt={region} className="h-3 w-4 rounded-[1px] object-cover shrink-0"/>
{region}
</span>
</SelectValue>
<SelectTrigger className="w-[70px] shrink-0">
<SelectValue placeholder="Region"/>
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{REGIONS.map((r) => (<SelectItem key={r} value={r} textValue={r}>
<span className="flex items-center gap-1.5">
<img src={`/assets/flags/${r.toLowerCase()}.svg`} alt="" className="h-3 w-4 rounded-[1px] object-cover shrink-0"/>
{r}{" "}
<span className="text-muted-foreground">
({getRegionName(r)})
</span>
{r}{" "}
<span className="text-muted-foreground">
({getRegionName(r)})
</span>
</SelectItem>))}
</SelectContent>
File diff suppressed because it is too large Load Diff
+11 -17
View File
@@ -6,19 +6,18 @@ import { ActivityIcon, type ActivityIconHandle } from "@/components/ui/activity"
import { TerminalIcon } from "@/components/ui/terminal";
import { FileMusicIcon, type FileMusicIconHandle } from "@/components/ui/file-music";
import { FilePenIcon, type FilePenIconHandle } from "@/components/ui/file-pen";
import { FileTextIcon, type FileTextIconHandle } from "@/components/ui/file-text";
import { BugReportIcon } from "@/components/ui/bug-report-icon";
import { CoffeeIcon } from "@/components/ui/coffee";
import { BadgeAlertIcon } from "@/components/ui/badge-alert";
import { GithubIcon } from "@/components/ui/github";
import { BlocksIcon } from "@/components/ui/blocks-icon";
import { AudioLinesIcon, type AudioLinesIconHandle } from "@/components/ui/audio-lines";
import { ToolCaseIcon } from "@/components/ui/tool-case";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { openExternal } from "@/lib/utils";
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "lyrics-manager" | "projects" | "support" | "history";
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "about" | "history";
interface SidebarProps {
currentPage: PageType;
onPageChange: (page: PageType) => void;
@@ -34,7 +33,6 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
const resamplerIconRef = useRef<AudioLinesIconHandle>(null);
const converterIconRef = useRef<FileMusicIconHandle>(null);
const fileManagerIconRef = useRef<FilePenIconHandle>(null);
const lyricsManagerIconRef = useRef<FileTextIconHandle>(null);
const handleIssuesDialogChange = (open: boolean) => {
setIsIssuesDialogOpen(open);
if (!open) {
@@ -101,8 +99,8 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
<Tooltip delayDuration={0}>
<DropdownMenuTrigger asChild>
<TooltipTrigger asChild>
<Button variant={["audio-analysis", "audio-converter", "audio-resampler", "file-manager", "lyrics-manager"].includes(currentPage) ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${["audio-analysis", "audio-converter", "audio-resampler", "file-manager", "lyrics-manager"].includes(currentPage) ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`}>
<ToolCaseIcon size={20}/>
<Button variant={["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`}>
<BlocksIcon size={20} loop={true}/>
</Button>
</TooltipTrigger>
</DropdownMenuTrigger>
@@ -127,10 +125,6 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
<FilePenIcon ref={fileManagerIconRef} size={16}/>
<span>File Manager</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onPageChange("lyrics-manager")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(lyricsManagerIconRef)}>
<FileTextIcon ref={lyricsManagerIconRef} size={16}/>
<span>Lyrics Manager</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -140,7 +134,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => setIsIssuesDialogOpen(true)}>
<BugReportIcon size={20} loop={true}/>
<GithubIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
@@ -182,23 +176,23 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
<Tooltip delayDuration={0}>
<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")}>
<BlocksIcon size={20} loop={true}/>
<Button variant={currentPage === "about" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "about" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("about")}>
<BadgeAlertIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Other Projects</p>
<p>About</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
<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}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Support Me</p>
<p>Support me on Ko-fi</p>
</TooltipContent>
</Tooltip>
</div>
-96
View File
@@ -1,96 +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>);
}
+3 -47
View File
@@ -1,9 +1,6 @@
import { X, Minus, Maximize, SlidersHorizontal, Globe, Eye, EyeOff } from "lucide-react";
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
import { Menubar, MenubarContent, MenubarMenu, MenubarItem, MenubarTrigger, MenubarLabel, MenubarSeparator } from "@/components/ui/menubar";
import { Slider } from "@/components/ui/slider";
import { getSettings, updateSettings } from "@/lib/settings";
import { PREVIEW_VOLUME_CHANGED_EVENT } from "@/lib/preview";
import { fetchCurrentIPInfo } from "@/lib/api";
import type { CurrentIPInfo } from "@/types/api";
import { openExternal } from "@/lib/utils";
@@ -27,12 +24,7 @@ const SPOTIFY_BLOCKED_COUNTRY_CODES = new Set([
"TM",
"YE",
]);
interface SettingsUpdatedDetail {
previewVolume?: number;
}
export function TitleBar() {
const initialSettings = getSettings();
const [previewVolume, setPreviewVolume] = useState(initialSettings.previewVolume ?? 100);
const [currentIPInfo, setCurrentIPInfo] = useState<CurrentIPInfo | null>(null);
const [isLoadingCurrentIPInfo, setIsLoadingCurrentIPInfo] = useState(false);
const [currentIPInfoError, setCurrentIPInfoError] = useState("");
@@ -41,16 +33,6 @@ export function TitleBar() {
useEffect(() => {
currentIPInfoRef.current = currentIPInfo;
}, [currentIPInfo]);
useEffect(() => {
const handleSettingsUpdate = (event: Event) => {
const updatedSettings = (event as CustomEvent<SettingsUpdatedDetail>).detail;
if (updatedSettings && typeof updatedSettings.previewVolume === "number") {
setPreviewVolume(updatedSettings.previewVolume);
}
};
window.addEventListener("settingsUpdated", handleSettingsUpdate);
return () => window.removeEventListener("settingsUpdated", handleSettingsUpdate);
}, []);
const loadCurrentIPInfo = async (options?: {
silent?: boolean;
}) => {
@@ -106,22 +88,6 @@ export function TitleBar() {
const handleClose = () => {
Quit();
};
const handlePreviewVolumeChange = (value: number[]) => {
const nextValue = value[0];
if (typeof nextValue !== "number" || Number.isNaN(nextValue)) {
return;
}
setPreviewVolume(nextValue);
window.dispatchEvent(new CustomEvent(PREVIEW_VOLUME_CHANGED_EVENT, { detail: nextValue }));
};
const handlePreviewVolumeCommit = (value: number[]) => {
const nextValue = value[0];
if (typeof nextValue !== "number" || Number.isNaN(nextValue)) {
return;
}
setPreviewVolume(nextValue);
void updateSettings({ previewVolume: nextValue });
};
const detectedCountryCode = currentIPInfo?.country_code?.toUpperCase() || "";
const detectedFlagPath = detectedCountryCode ? `/assets/flags/${detectedCountryCode.toLowerCase()}.svg` : "";
const isSpotifyBlockedCountry = detectedCountryCode !== "" && SPOTIFY_BLOCKED_COUNTRY_CODES.has(detectedCountryCode);
@@ -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">
<SlidersHorizontal className="w-3.5 h-3.5"/>
</MenubarTrigger>
<MenubarContent align="end" className="min-w-70">
<div className="px-2 py-1.5 space-y-2">
<div className="flex items-center justify-between gap-3">
<MenubarLabel className="p-0">Preview Volume</MenubarLabel>
<span className="text-xs font-medium text-muted-foreground tabular-nums">
{previewVolume}%
</span>
</div>
<Slider value={[previewVolume]} min={0} max={100} step={5} onValueChange={handlePreviewVolumeChange} onValueCommit={handlePreviewVolumeCommit} aria-label="Preview volume"/>
</div>
<MenubarSeparator />
<MenubarContent align="end" className="min-w-[280px]">
<div className="flex items-center gap-1.5 px-2 py-1.5">
<MenubarLabel className="p-0">Network</MenubarLabel>
{isSpotifyBlockedCountry && (<span className="text-xs font-medium text-destructive">
@@ -156,7 +112,7 @@ export function TitleBar() {
<div className="px-2 py-1.5 space-y-1">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
{detectedFlagPath ? (<img src={detectedFlagPath} alt={detectedCountryCode} className="h-3.5 w-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">
{isLoadingCurrentIPInfo
? "Detecting..."
@@ -176,7 +132,7 @@ export function TitleBar() {
</div>)}
</div>
<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"/>
<span>Website</span>
</MenubarItem>
+6 -6
View File
@@ -6,7 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/toolti
import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { usePreview } from "@/hooks/usePreview";
import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks";
import { buildClickableArtists, getClickableArtistKey } from "@/lib/artist-links";
import { buildClickableArtists } from "@/lib/artist-links";
interface TrackInfoProps {
track: TrackMetadata & {
album_name: string;
@@ -83,14 +83,14 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
{isSkipped ? (<FileCheck className="h-6 w-6 text-yellow-500 shrink-0"/>) : isDownloaded ? (<CheckCircle className="h-6 w-6 text-green-500 shrink-0"/>) : isFailed ? (<XCircle className="h-6 w-6 text-red-500 shrink-0"/>) : null}
</div>
<p className="text-lg text-muted-foreground">
{clickableArtists.length > 0 ? clickableArtists.map((artist, index) => (<span key={getClickableArtistKey(artist)}>
{onArtistClick ? (<button type="button" className="cursor-pointer rounded-sm bg-transparent p-0 text-inherit hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60" onClick={() => onArtistClick({
{clickableArtists.length > 0 ? clickableArtists.map((artist, index) => (<span key={`${artist.id || artist.name}-${index}`}>
{onArtistClick ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
id: artist.id,
name: artist.name,
external_urls: artist.external_urls,
})}>
{artist.name}
</button>) : (artist.name)}
</span>) : (artist.name)}
{index < clickableArtists.length - 1 && ", "}
</span>)) : track.artists}
</p>
@@ -99,13 +99,13 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
<div className="space-y-1">
<div>
<p className="text-xs text-muted-foreground">Album</p>
<p className="font-medium truncate">{hasAlbumClick ? (<button type="button" className="cursor-pointer rounded-sm bg-transparent p-0 text-left text-inherit hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60" onClick={() => onAlbumClick?.({
<p className="font-medium truncate">{hasAlbumClick ? (<span className="cursor-pointer hover:underline" onClick={() => onAlbumClick?.({
id: track.album_id!,
name: track.album_name,
external_urls: track.album_url!,
})}>
{track.album_name}
</button>) : (track.album_name)}</p>
</span>) : (track.album_name)}</p>
</div>
{track.plays && (<div>
<p className="text-xs text-muted-foreground">Total Plays</p>
+9 -10
View File
@@ -7,7 +7,7 @@ import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, Pagi
import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { usePreview } from "@/hooks/usePreview";
import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks";
import { buildClickableArtists, getClickableArtistKey } from "@/lib/artist-links";
import { buildClickableArtists } from "@/lib/artist-links";
interface TrackListProps {
tracks: TrackMetadata[];
searchQuery: string;
@@ -55,7 +55,6 @@ interface TrackListProps {
}
export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, currentPage, itemsPerPage, showCheckboxes = false, hideAlbumColumn = false, folderName, isArtistDiscography = false, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onCheckAvailability, onDownloadCover, onPageChange, onAlbumClick, onArtistClick, onTrackClick, }: TrackListProps) {
const { playPreview, loadingPreview, playingTrack } = usePreview();
const getTrackKey = (track: TrackMetadata) => track.spotify_id || track.external_urls || `${track.name}-${track.album_name}-${track.disc_number ?? 1}-${track.track_number}`;
let filteredTracks = tracks.filter((track) => {
if (!searchQuery)
return true;
@@ -220,7 +219,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
</tr>
</thead>
<tbody>
{paginatedTracks.map((track, index) => (<tr key={getTrackKey(track)} className="border-b transition-colors hover:bg-muted/50">
{paginatedTracks.map((track, index) => (<tr key={index} className="border-b transition-colors hover:bg-muted/50">
{showCheckboxes && (<td className="p-4 align-middle">
{track.spotify_id && (<Checkbox checked={selectedTracks.includes(track.spotify_id)} onCheckedChange={() => onToggleTrack(track.spotify_id!)}/>)}
</td>)}
@@ -243,9 +242,9 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
{track.images && (<img src={track.images} alt={track.name} className="w-10 h-10 rounded object-cover"/>)}
<div className="flex flex-col">
<div className="flex items-center gap-2">
{onTrackClick ? (<button type="button" className="font-medium cursor-pointer rounded-sm bg-transparent p-0 text-left text-inherit hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60" onClick={() => onTrackClick(track)}>
{onTrackClick ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onTrackClick(track)}>
{track.name}
</button>) : (<span className="font-medium">{track.name}</span>)}
</span>) : (<span className="font-medium">{track.name}</span>)}
{track.is_explicit && (<span className="inline-flex items-center justify-center bg-red-600 text-white text-[10px] h-4 w-4 rounded shrink-0" title="Explicit">E</span>)}
{track.spotify_id && skippedTracks.has(track.spotify_id) ? (<FileCheck className="h-4 w-4 text-yellow-500 shrink-0"/>) : track.spotify_id && downloadedTracks.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500 shrink-0"/>) : track.spotify_id && failedTracks.has(track.spotify_id) ? (<XCircle className="h-4 w-4 text-red-500 shrink-0"/>) : null}
@@ -256,14 +255,14 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
if (clickableArtists.length === 0) {
return track.artists;
}
return clickableArtists.map((artist, i) => (<span key={getClickableArtistKey(artist)}>
{onArtistClick ? (<button type="button" className="cursor-pointer rounded-sm bg-transparent p-0 text-inherit hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60" onClick={() => onArtistClick({
return clickableArtists.map((artist, i) => (<span key={`${artist.id || artist.name}-${i}`}>
{onArtistClick ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
id: artist.id,
name: artist.name,
external_urls: artist.external_urls,
})}>
{artist.name}
</button>) : (artist.name)}
</span>) : (artist.name)}
{i < clickableArtists.length - 1 && ", "}
</span>));
})()}
@@ -272,13 +271,13 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
</div>
</td>
{!hideAlbumColumn && (<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
{onAlbumClick && track.album_id && track.album_url ? (<button type="button" className="cursor-pointer rounded-sm bg-transparent p-0 text-left text-inherit hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60" onClick={() => onAlbumClick({
{onAlbumClick && track.album_id && track.album_url ? (<span className="cursor-pointer hover:underline" onClick={() => onAlbumClick({
id: track.album_id!,
name: track.album_name,
external_urls: track.album_url!,
})}>
{track.album_name}
</button>) : (track.album_name)}
</span>) : (track.album_name)}
</td>)}
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
{formatDuration(track.duration_ms)}
@@ -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,117 +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 };
-65
View File
@@ -1,65 +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 FileTextIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface FileTextIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const PATH_VARIANTS: Variants = {
normal: {
pathLength: 1,
opacity: 1,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
transition: {
duration: 0.6,
ease: 'easeInOut',
},
},
};
const FileTextIcon = forwardRef<FileTextIconHandle, FileTextIconProps>(({ 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="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="M14 2v5a1 1 0 0 0 1 1h5" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="M10 9H8" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="M16 13H8" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="M16 17H8" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
</svg>
</div>);
});
FileTextIcon.displayName = 'FileTextIcon';
export { FileTextIcon };
+102
View File
@@ -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 };
+7 -17
View File
@@ -37,24 +37,14 @@ function SelectContent({ className, children, position = "popper", align = "cent
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (<SelectPrimitive.Label data-slot="select-label" className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} {...props}/>);
}
function SelectItem({ className, children, indicatorPosition = "right", trailingAction, ...props }: React.ComponentProps<typeof SelectPrimitive.Item> & {
indicatorPosition?: "right" | "inline";
trailingAction?: React.ReactNode;
}) {
return (<SelectPrimitive.Item data-slot="select-item" className={cn("focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", indicatorPosition === "right" ? "pr-8" : "pr-2", trailingAction ? "pr-10" : undefined, className)} {...props}>
<span className="flex min-w-0 items-center gap-2">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{indicatorPosition === "inline" && (<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4"/>
</SelectPrimitive.ItemIndicator>)}
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (<SelectPrimitive.Item data-slot="select-item" className={cn("focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", className)} {...props}>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4"/>
</SelectPrimitive.ItemIndicator>
</span>
{trailingAction ? (<span className="absolute right-2 flex items-center justify-center">
{trailingAction}
</span>) : indicatorPosition === "right" ? (<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4"/>
</SelectPrimitive.ItemIndicator>
</span>) : null}
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>);
}
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
-17
View File
@@ -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 };
-78
View File
@@ -1,78 +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 -3
View File
@@ -1,5 +1,5 @@
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() {
const [state, setState] = useState(getApiStatusState);
useEffect(() => {
@@ -11,7 +11,5 @@ export function useApiStatus() {
...state,
sources: API_SOURCES,
checkOne: (sourceId: string) => checkApiStatus(sourceId),
checkAllCurrent: () => checkCurrentApiStatusesOnly(),
checkAllNext: () => checkSpotiFLACNextStatusesOnly(),
};
}
+45 -90
View File
@@ -1,6 +1,6 @@
import { useState, useRef } from "react";
import { downloadTrack, fetchSpotifyMetadata } from "@/lib/api";
import { getSettings, 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 { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils";
import { logger } from "@/lib/logger";
@@ -36,17 +36,13 @@ const GetTrackISRC = (spotifyId: string): Promise<string> => (window as any)["go
async function resolveTemplateISRC(settings: {
folderTemplate?: string;
filenameTemplate?: string;
existingFileCheckMode?: string;
}, spotifyId?: string): Promise<string> {
if (!spotifyId) {
return "";
}
const folderTemplate = settings.folderTemplate || "";
const filenameTemplate = settings.filenameTemplate || "";
const shouldResolveISRC = settings.existingFileCheckMode === "isrc" ||
folderTemplate.includes("{isrc}") ||
filenameTemplate.includes("{isrc}");
if (!shouldResolveISRC) {
if (!folderTemplate.includes("{isrc}") && !filenameTemplate.includes("{isrc}")) {
return "";
}
try {
@@ -56,18 +52,26 @@ async function resolveTemplateISRC(settings: {
return "";
}
}
function getTidalVariant(settings: any): "tidal" | "alt" {
return settings?.tidalVariant === "alt" ? "alt" : "tidal";
}
function isTidalAltVariant(settings: any): boolean {
return getTidalVariant(settings) === "alt";
}
function getTidalAudioFormat(settings: any, mode: "single" | "auto"): "LOSSLESS" | "HI_RES_LOSSLESS" {
if (isTidalAltVariant(settings)) {
return "LOSSLESS";
}
if (mode === "auto") {
return (settings.autoQuality || "24") === "24" ? "HI_RES_LOSSLESS" : "LOSSLESS";
}
return settings.tidalQuality || "LOSSLESS";
}
function shouldFetchStreamingURLs(order: string[]): boolean {
return order.includes("amazon") || order.includes("tidal");
function shouldFetchStreamingURLs(order: string[], settings: any): boolean {
return order.includes("amazon") || (order.includes("tidal") && !isTidalAltVariant(settings));
}
export function useDownload(region: string) {
const [downloadProgress, setDownloadProgress] = useState<number>(0);
const [downloadRemainingCount, setDownloadRemainingCount] = useState<number>(0);
const [isDownloading, setIsDownloading] = useState(false);
const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null);
const [bulkDownloadType, setBulkDownloadType] = useState<"all" | "selected" | null>(null);
@@ -79,22 +83,10 @@ export function useDownload(region: string) {
artists: string;
} | null>(null);
const shouldStopDownloadRef = useRef(false);
const updateBatchProgress = (completedCount: number, totalCount: number) => {
const safeTotalCount = Math.max(0, totalCount);
const safeCompletedCount = Math.min(Math.max(0, completedCount), safeTotalCount);
setDownloadProgress(safeTotalCount > 0 ? Math.min(100, Math.round((safeCompletedCount / safeTotalCount) * 100)) : 0);
setDownloadRemainingCount(Math.max(0, safeTotalCount - safeCompletedCount));
};
const downloadWithAutoFallback = async (id: string, settings: any, trackName?: string, artistName?: string, albumName?: string, playlistName?: string, position?: number, spotifyId?: string, durationMs?: number, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
const service = settings.downloader;
const query = trackName && artistName ? `${trackName} ${artistName} ` : undefined;
const os = settings.operatingSystem;
const customTidalApi = typeof settings.customTidalApi === "string" && settings.customTidalApi.trim().startsWith("https://")
? settings.customTidalApi.trim().replace(/\/+$/g, "")
: undefined;
const customQobuzApi = typeof settings.customQobuzApi === "string" && settings.customQobuzApi.trim().startsWith("https://")
? settings.customQobuzApi.trim().replace(/\/+$/g, "")
: undefined;
let outputDir = settings.downloadPath;
let useAlbumTrackNumber = false;
const placeholder = "__SLASH_PLACEHOLDER__";
@@ -196,9 +188,11 @@ export function useDownload(region: string) {
itemID = await AddToDownloadQueue(id, trackName || "", displayArtist || "", albumName || "");
}
if (service === "auto") {
const order = sanitizeAutoOrder(settings.autoOrder).split("-");
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
const tidalVariant = getTidalVariant(settings);
const tidalLabel = tidalVariant === "alt" ? "Tidal Alt." : "Tidal";
let streamingURLs: any = null;
if (spotifyId && shouldFetchStreamingURLs(order)) {
if (spotifyId && shouldFetchStreamingURLs(order, settings)) {
try {
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
const urlsJson = await GetStreamingURLs(spotifyId, region);
@@ -215,9 +209,9 @@ export function useDownload(region: string) {
const is24Bit = (settings.autoQuality || "24") === "24";
const qobuzQuality = is24Bit ? "27" : "6";
for (const s of order) {
if (s === "tidal" && streamingURLs?.tidal_url) {
if (s === "tidal" && ((tidalVariant === "alt" && spotifyId) || streamingURLs?.tidal_url)) {
try {
logger.debug(`trying Tidal for: ${trackName} - ${artistName}`);
logger.debug(`trying ${tidalLabel} for: ${trackName} - ${artistName}`);
const response = await downloadTrack({
service: "tidal",
query,
@@ -235,11 +229,11 @@ export function useDownload(region: string) {
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
service_url: streamingURLs?.tidal_url,
service_url: tidalVariant === "alt" ? undefined : streamingURLs?.tidal_url,
tidal_variant: tidalVariant,
duration: durationSeconds,
item_id: itemID,
audio_format: tidalQuality,
tidal_api_url: customTidalApi,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
@@ -252,17 +246,17 @@ export function useDownload(region: string) {
embed_genre: settings.embedGenre,
});
if (response.success) {
logger.success(`Tidal: ${trackName} - ${artistName}`);
logger.success(`${tidalLabel}: ${trackName} - ${artistName}`);
return response;
}
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Tidal] ${errMsg}`);
fallbackErrors.push(`[${tidalLabel}] ${errMsg}`);
lastResponse = response;
logger.warning(`Tidal failed, trying next...`);
logger.warning(`${tidalLabel} failed, trying next...`);
}
catch (err) {
logger.error(`Tidal error: ${err}`);
fallbackErrors.push(`[Tidal] ${String(err)}`);
logger.error(`${tidalLabel} error: ${err}`);
fallbackErrors.push(`[${tidalLabel}] ${String(err)}`);
lastResponse = { success: false, error: String(err) };
}
}
@@ -288,7 +282,6 @@ export function useDownload(region: string) {
embed_max_quality_cover: settings.embedMaxQualityCover,
service_url: streamingURLs.amazon_url,
item_id: itemID,
audio_format: is24Bit ? "24" : "16",
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
@@ -336,7 +329,6 @@ export function useDownload(region: string) {
embed_max_quality_cover: settings.embedMaxQualityCover,
item_id: itemID,
audio_format: qobuzQuality,
qobuz_api_url: customQobuzApi,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
@@ -378,9 +370,6 @@ export function useDownload(region: string) {
else if (service === "qobuz") {
audioFormat = settings.qobuzQuality || "6";
}
else if (service === "amazon") {
audioFormat = settings.amazonQuality || "16";
}
else if (service === "deezer") {
audioFormat = "flac";
}
@@ -405,8 +394,7 @@ export function useDownload(region: string) {
duration: durationSecondsForFallback,
item_id: itemID,
audio_format: audioFormat,
tidal_api_url: service === "tidal" ? customTidalApi : undefined,
qobuz_api_url: service === "qobuz" ? customQobuzApi : undefined,
tidal_variant: service === "tidal" ? getTidalVariant(settings) : undefined,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
@@ -428,12 +416,6 @@ export function useDownload(region: string) {
const service = settings.downloader;
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
const os = settings.operatingSystem;
const customTidalApi = typeof settings.customTidalApi === "string" && settings.customTidalApi.trim().startsWith("https://")
? settings.customTidalApi.trim().replace(/\/+$/g, "")
: undefined;
const customQobuzApi = typeof settings.customQobuzApi === "string" && settings.customQobuzApi.trim().startsWith("https://")
? settings.customQobuzApi.trim().replace(/\/+$/g, "")
: undefined;
let outputDir = settings.downloadPath;
let useAlbumTrackNumber = false;
const placeholder = "__SLASH_PLACEHOLDER__";
@@ -492,9 +474,11 @@ export function useDownload(region: string) {
}
}
if (service === "auto") {
const order = sanitizeAutoOrder(settings.autoOrder).split("-");
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
const tidalVariant = getTidalVariant(settings);
const tidalLabel = tidalVariant === "alt" ? "Tidal Alt." : "Tidal";
let streamingURLs: any = null;
if (spotifyId && shouldFetchStreamingURLs(order)) {
if (spotifyId && shouldFetchStreamingURLs(order, settings)) {
try {
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
const urlsJson = await GetStreamingURLs(spotifyId, region);
@@ -511,9 +495,9 @@ export function useDownload(region: string) {
const is24Bit = (settings.autoQuality || "24") === "24";
const qobuzQuality = is24Bit ? "27" : "6";
for (const s of order) {
if (s === "tidal" && streamingURLs?.tidal_url) {
if (s === "tidal" && ((tidalVariant === "alt" && spotifyId) || streamingURLs?.tidal_url)) {
try {
logger.debug(`trying Tidal for: ${trackName} - ${artistName}`);
logger.debug(`trying ${tidalLabel} for: ${trackName} - ${artistName}`);
const response = await downloadTrack({
service: "tidal",
query,
@@ -531,11 +515,11 @@ export function useDownload(region: string) {
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
service_url: streamingURLs?.tidal_url,
service_url: tidalVariant === "alt" ? undefined : streamingURLs?.tidal_url,
tidal_variant: tidalVariant,
duration: durationSeconds,
item_id: itemID,
audio_format: tidalQuality,
tidal_api_url: customTidalApi,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
@@ -548,17 +532,17 @@ export function useDownload(region: string) {
embed_genre: settings.embedGenre,
});
if (response.success) {
logger.success(`Tidal: ${trackName} - ${artistName}`);
logger.success(`${tidalLabel}: ${trackName} - ${artistName}`);
return response;
}
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Tidal] ${errMsg}`);
fallbackErrors.push(`[${tidalLabel}] ${errMsg}`);
lastResponse = response;
logger.warning(`Tidal failed, trying next...`);
logger.warning(`${tidalLabel} failed, trying next...`);
}
catch (err) {
logger.error(`Tidal error: ${err}`);
fallbackErrors.push(`[Tidal] ${String(err)}`);
logger.error(`${tidalLabel} error: ${err}`);
fallbackErrors.push(`[${tidalLabel}] ${String(err)}`);
lastResponse = { success: false, error: String(err) };
}
}
@@ -633,7 +617,6 @@ export function useDownload(region: string) {
duration: durationSeconds,
item_id: itemID,
audio_format: qobuzQuality,
qobuz_api_url: customQobuzApi,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
@@ -676,9 +659,6 @@ export function useDownload(region: string) {
else if (service === "qobuz") {
audioFormat = settings.qobuzQuality || "6";
}
else if (service === "amazon") {
audioFormat = settings.amazonQuality || "16";
}
const singleServiceResponse = await downloadTrack({
service: service as "tidal" | "qobuz" | "amazon",
query,
@@ -699,8 +679,7 @@ export function useDownload(region: string) {
duration: durationSecondsForFallback,
item_id: itemID,
audio_format: audioFormat,
tidal_api_url: service === "tidal" ? customTidalApi : undefined,
qobuz_api_url: service === "qobuz" ? customQobuzApi : undefined,
tidal_variant: service === "tidal" ? getTidalVariant(settings) : undefined,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
@@ -768,8 +747,6 @@ export function useDownload(region: string) {
setIsDownloading(true);
setBulkDownloadType("selected");
setDownloadProgress(0);
setDownloadRemainingCount(selectedTracks.length);
setCurrentDownloadInfo(null);
let outputDir = settings.downloadPath;
const os = settings.operatingSystem;
const useAlbumTag = settings.folderTemplate?.includes("{album}");
@@ -838,7 +815,7 @@ export function useDownload(region: string) {
let errorCount = 0;
let skippedCount = existingSpotifyIDs.size;
const total = selectedTracks.length;
updateBatchProgress(skippedCount, total);
setDownloadProgress(Math.round((skippedCount / total) * 100));
for (let i = 0; i < tracksToDownload.length; i++) {
if (shouldStopDownloadRef.current) {
toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`);
@@ -854,10 +831,6 @@ export function useDownload(region: string) {
try {
const releaseYear = track.release_date?.substring(0, 4);
const response = await downloadWithItemID(settings, itemID, track.name, track.artists, track.album_name, folderName, originalIndex + 1, track.spotify_id, track.duration_ms, isAlbum, releaseYear, track.album_artist || "", track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher);
if (response.cancelled || shouldStopDownloadRef.current) {
toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`);
break;
}
if (response.success) {
if (response.already_exists) {
skippedCount++;
@@ -895,13 +868,12 @@ export function useDownload(region: string) {
}
}
const completedCount = skippedCount + successCount + errorCount;
updateBatchProgress(completedCount, total);
setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
}
setDownloadingTrack(null);
setCurrentDownloadInfo(null);
setIsDownloading(false);
setBulkDownloadType(null);
updateBatchProgress(0, 0);
shouldStopDownloadRef.current = false;
const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App");
await CancelAllQueuedItems();
@@ -950,8 +922,6 @@ export function useDownload(region: string) {
setIsDownloading(true);
setBulkDownloadType("all");
setDownloadProgress(0);
setDownloadRemainingCount(tracksWithId.length);
setCurrentDownloadInfo(null);
let outputDir = settings.downloadPath;
const os = settings.operatingSystem;
const useAlbumTag = settings.folderTemplate?.includes("{album}");
@@ -1015,7 +985,7 @@ export function useDownload(region: string) {
let errorCount = 0;
let skippedCount = existingSpotifyIDs.size;
const total = tracksWithId.length;
updateBatchProgress(skippedCount, total);
setDownloadProgress(Math.round((skippedCount / total) * 100));
for (let i = 0; i < tracksToDownload.length; i++) {
if (shouldStopDownloadRef.current) {
toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`);
@@ -1031,10 +1001,6 @@ export function useDownload(region: string) {
try {
const releaseYear = track.release_date?.substring(0, 4);
const response = await downloadWithItemID(settings, itemID, track.name, track.artists, track.album_name, folderName, originalIndex + 1, track.spotify_id, track.duration_ms, isAlbum, releaseYear, track.album_artist || "", track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher);
if (response.cancelled || shouldStopDownloadRef.current) {
toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`);
break;
}
if (response.success) {
if (response.already_exists) {
skippedCount++;
@@ -1069,13 +1035,12 @@ export function useDownload(region: string) {
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
}
const completedCount = skippedCount + successCount + errorCount;
updateBatchProgress(completedCount, total);
setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
}
setDownloadingTrack(null);
setCurrentDownloadInfo(null);
setIsDownloading(false);
setBulkDownloadType(null);
updateBatchProgress(0, 0);
shouldStopDownloadRef.current = false;
const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App");
await CancelQueued();
@@ -1113,15 +1078,6 @@ export function useDownload(region: string) {
const handleStopDownload = () => {
logger.info("download stopped by user");
shouldStopDownloadRef.current = true;
void (async () => {
try {
const { ForceStopDownloads } = await import("../../wailsjs/go/main/App");
await ForceStopDownloads();
}
catch (err) {
console.error("Failed to force stop downloads:", err);
}
})();
toast.info("Stopping download...");
};
const resetDownloadedTracks = () => {
@@ -1131,7 +1087,6 @@ export function useDownload(region: string) {
};
return {
downloadProgress,
downloadRemainingCount,
isDownloading,
downloadingTrack,
bulkDownloadType,
@@ -4,16 +4,12 @@ export interface DownloadProgressInfo {
is_downloading: boolean;
mb_downloaded: number;
speed_mbps: number;
rate_limited?: boolean;
rate_limit_secs?: number;
}
export function useDownloadProgress() {
const [progress, setProgress] = useState<DownloadProgressInfo>({
is_downloading: false,
mb_downloaded: 0,
speed_mbps: 0,
rate_limited: false,
rate_limit_secs: 0,
});
const intervalRef = useRef<number | null>(null);
useEffect(() => {
+1 -5
View File
@@ -9,17 +9,13 @@ const GetTrackISRC = (spotifyId: string): Promise<string> => (window as any)["go
async function resolveTemplateISRC(settings: {
folderTemplate?: string;
filenameTemplate?: string;
existingFileCheckMode?: string;
}, spotifyId?: string): Promise<string> {
if (!spotifyId) {
return "";
}
const folderTemplate = settings.folderTemplate || "";
const filenameTemplate = settings.filenameTemplate || "";
const shouldResolveISRC = settings.existingFileCheckMode === "isrc" ||
folderTemplate.includes("{isrc}") ||
filenameTemplate.includes("{isrc}");
if (!shouldResolveISRC) {
if (!folderTemplate.includes("{isrc}") && !filenameTemplate.includes("{isrc}")) {
return "";
}
try {
-1
View File
@@ -159,7 +159,6 @@ export function useMetadata() {
info: info,
image: image,
data: jsonStr,
is_explicit: ("track" in data && Boolean(data.track.is_explicit)) || ("album_info" in data && Boolean(data.album_info.is_explicit)),
timestamp: Math.floor(Date.now() / 1000)
});
}
+27 -32
View File
@@ -1,34 +1,32 @@
import { useEffect, useRef, useState } from "react";
import { useState, useEffect } from "react";
import { GetPreviewURL } from "@/../wailsjs/go/main/App";
import { getPreviewVolume } from "@/lib/preview";
import { createPreviewPlayback, type PreviewPlayback } from "@/lib/preview-player";
import { SPOTIFY_PREVIEW_VOLUME } from "@/lib/preview";
import { toast } from "sonner";
export function usePreview() {
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
const [currentAudio, setCurrentAudio] = useState<HTMLAudioElement | null>(null);
const [playingTrack, setPlayingTrack] = useState<string | null>(null);
const currentPlaybackRef = useRef<PreviewPlayback | null>(null);
const stopCurrentAudio = () => {
if (!currentPlaybackRef.current) {
return;
}
currentPlaybackRef.current.destroy();
currentPlaybackRef.current = null;
};
useEffect(() => {
return () => {
stopCurrentAudio();
if (currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
}
};
}, []);
}, [currentAudio]);
const playPreview = async (trackId: string, trackName: string) => {
try {
const currentAudio = currentPlaybackRef.current?.audio;
if (playingTrack === trackId && currentAudio) {
stopCurrentAudio();
currentAudio.pause();
currentAudio.currentTime = 0;
setPlayingTrack(null);
setCurrentAudio(null);
return;
}
if (currentAudio) {
stopCurrentAudio();
currentAudio.pause();
currentAudio.currentTime = 0;
setCurrentAudio(null);
setPlayingTrack(null);
}
setLoadingPreview(trackId);
@@ -40,18 +38,15 @@ export function usePreview() {
setLoadingPreview(null);
return;
}
const playback = await createPreviewPlayback(previewURL, getPreviewVolume());
const audio = playback.audio;
const audio = new Audio(previewURL);
audio.volume = SPOTIFY_PREVIEW_VOLUME;
audio.addEventListener("loadeddata", () => {
setLoadingPreview(null);
setPlayingTrack(trackId);
});
audio.addEventListener("ended", () => {
setPlayingTrack(null);
if (currentPlaybackRef.current?.audio === audio) {
currentPlaybackRef.current.destroy();
currentPlaybackRef.current = null;
}
setCurrentAudio(null);
});
audio.addEventListener("error", () => {
toast.error("Failed to play preview", {
@@ -59,27 +54,27 @@ export function usePreview() {
});
setLoadingPreview(null);
setPlayingTrack(null);
if (currentPlaybackRef.current?.audio === audio) {
currentPlaybackRef.current.destroy();
currentPlaybackRef.current = null;
}
setCurrentAudio(null);
});
currentPlaybackRef.current = playback;
setCurrentAudio(audio);
await audio.play();
}
catch (error: unknown) {
stopCurrentAudio();
catch (error: any) {
console.error("Preview error:", error);
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);
setPlayingTrack(null);
}
};
const stopPreview = () => {
stopCurrentAudio();
setPlayingTrack(null);
if (currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
setCurrentAudio(null);
setPlayingTrack(null);
}
};
return {
playPreview,
-15
View File
@@ -96,21 +96,6 @@
}
}
@media (prefers-reduced-motion: reduce) {
html:focus-within {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
@theme inline {
--font-mono: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
+62 -129
View File
@@ -1,3 +1,4 @@
import { CheckAPIStatus } from "../../wailsjs/go/main/App";
import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout";
export type ApiCheckStatus = "checking" | "online" | "offline" | "idle";
export interface ApiSource {
@@ -9,27 +10,35 @@ export interface ApiSource {
interface SpotiFLACNextSource {
id: string;
name: string;
statusKey?: string;
statusPrefix?: string;
}
type SpotiFLACNextStatusResponse = Partial<Record<string, string>>;
type SpotiFLACNextStatusResponse = {
tidal?: string;
qobuz_a?: string;
qobuz_b?: string;
qobuz_c?: string;
deezer_a?: string;
deezer_b?: string;
amazon_a?: string;
amazon_b?: string;
amazon_c?: string;
apple?: string;
};
export const API_SOURCES: ApiSource[] = [
{ id: "tidal", type: "tidal", name: "Tidal", url: "" },
{ id: "qobuz", type: "qobuz", name: "Qobuz", url: "" },
{ id: "amazon", type: "amazon", name: "Amazon Music", url: "" },
{ id: "musicbrainz", type: "musicbrainz", name: "MusicBrainz", url: "https://musicbrainz.org" },
];
export const SPOTIFLAC_NEXT_SOURCES: SpotiFLACNextSource[] = [
{ id: "tidal", name: "Tidal", statusPrefix: "tidal_" },
{ id: "qobuz", name: "Qobuz", statusPrefix: "qobuz_" },
{ id: "amazon", name: "Amazon Music", statusPrefix: "amazon_" },
{ id: "deezer", name: "Deezer", statusPrefix: "deezer_" },
{ id: "apple", name: "Apple Music", statusKey: "apple" },
{ id: "tidal", name: "Tidal" },
{ id: "qobuz", name: "Qobuz" },
{ id: "amazon", name: "Amazon Music" },
{ id: "deezer", name: "Deezer" },
{ id: "apple", name: "Apple Music" },
];
const SPOTIFLAC_STATUS_URL = "https://gist.githubusercontent.com/afkarxyz/6e57cd362cbd67f889e3a91a76254a5e/raw";
const SPOTIFLAC_CURRENT_STATUS_URL = "https://gist.githubusercontent.com/afkarxyz/7e392bc94ec2faaf74ef7d80025636eb/raw";
const SPOTIFLAC_STATUS_MAX_ATTEMPTS = 3;
const SPOTIFLAC_STATUS_RETRY_DELAY_MS = 1200;
const LogStatusConsole = (level: string, message: string): Promise<void> => (window as any)["go"]["main"]["App"]["LogStatusConsole"](level, message);
const SPOTIFLAC_NEXT_STATUS_URL = "https://status.spotbye.qzz.io/status";
const SPOTIFLAC_NEXT_MAX_ATTEMPTS = 3;
const SPOTIFLAC_NEXT_RETRY_DELAY_MS = 1200;
type ApiStatusState = {
checkingSources: Record<string, boolean>;
statuses: Record<string, ApiCheckStatus>;
@@ -40,10 +49,7 @@ let apiStatusState: ApiStatusState = {
statuses: {},
nextStatuses: {},
};
let activeCheckCurrentOnly: Promise<void> | null = null;
let activeCheckNextOnly: Promise<void> | null = null;
let activeStatusPayloadFetch: Promise<SpotiFLACNextStatusResponse> | null = null;
let activeCurrentStatusPayloadFetch: Promise<SpotiFLACNextStatusResponse> | null = null;
const activeSourceChecks = new Map<string, Promise<void>>();
const listeners = new Set<() => void>();
function emitApiStatusChange() {
@@ -55,38 +61,23 @@ function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState)
apiStatusState = updater(apiStatusState);
emitApiStatusChange();
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function sendStatusConsole(level: "info" | "warning" | "error", message: string): void {
async function checkSourceStatus(source: ApiSource): Promise<ApiCheckStatus> {
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 {
return;
return "offline";
}
}
function logStatusError(message: string): void {
sendStatusConsole("error", message);
function statusFromNextValue(value: string | undefined): ApiCheckStatus {
return value === "up" ? "online" : "offline";
}
function anyNextVariantUp(values: Array<string | undefined>): ApiCheckStatus {
return values.some((value) => value === "up") ? "online" : "offline";
}
function getNextSourceValues(payload: SpotiFLACNextStatusResponse, source: SpotiFLACNextSource): string[] {
if (source.statusKey) {
const value = payload[source.statusKey];
return typeof value === "string" ? [value] : [];
}
if (!source.statusPrefix) {
return [];
}
const values: string[] = [];
for (const [key, value] of Object.entries(payload)) {
if (key.startsWith(source.statusPrefix) && typeof value === "string") {
values.push(value);
}
}
return values;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function getSafeNextStatusesFallback(currentStatuses: Record<string, ApiCheckStatus>): Record<string, ApiCheckStatus> {
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
@@ -95,86 +86,40 @@ function getSafeNextStatusesFallback(currentStatuses: Record<string, ApiCheckSta
return acc;
}, {});
}
function hasCurrentResults(): boolean {
return API_SOURCES.some((source) => {
const status = apiStatusState.statuses[source.id];
return status === "online" || status === "offline";
});
}
function hasSpotiFLACNextResults(): boolean {
return SPOTIFLAC_NEXT_SOURCES.some((source) => {
const status = apiStatusState.nextStatuses[source.id];
return status === "online" || status === "offline";
});
}
async function fetchStatusPayloadOnce(url: string): Promise<SpotiFLACNextStatusResponse> {
const response = await withTimeout(fetch(url, {
async function fetchSpotiFLACNextStatusesOnce(): Promise<Record<string, ApiCheckStatus>> {
const response = await withTimeout(fetch(SPOTIFLAC_NEXT_STATUS_URL, {
method: "GET",
cache: "no-store",
headers: {
Accept: "application/json",
},
}), CHECK_TIMEOUT_MS, "SpotiFLAC status check timed out after 10 seconds");
}), CHECK_TIMEOUT_MS, "SpotiFLAC Next status check timed out after 10 seconds");
if (!response.ok) {
throw new Error(`SpotiFLAC status returned ${response.status}`);
throw new Error(`SpotiFLAC Next status returned ${response.status}`);
}
return (await response.json()) as SpotiFLACNextStatusResponse;
const payload = (await response.json()) as SpotiFLACNextStatusResponse;
return {
tidal: statusFromNextValue(payload.tidal),
qobuz: anyNextVariantUp([payload.qobuz_a, payload.qobuz_b, payload.qobuz_c]),
deezer: anyNextVariantUp([payload.deezer_a, payload.deezer_b]),
amazon: anyNextVariantUp([payload.amazon_a, payload.amazon_b, payload.amazon_c]),
apple: statusFromNextValue(payload.apple),
};
}
async function fetchStatusPayloadWithRetry(url: string): Promise<SpotiFLACNextStatusResponse> {
async function checkSpotiFLACNextStatuses(): Promise<Record<string, ApiCheckStatus>> {
let lastError: unknown = null;
for (let attempt = 1; attempt <= SPOTIFLAC_STATUS_MAX_ATTEMPTS; attempt++) {
for (let attempt = 1; attempt <= SPOTIFLAC_NEXT_MAX_ATTEMPTS; attempt++) {
try {
return await fetchStatusPayloadOnce(url);
return await fetchSpotiFLACNextStatusesOnce();
}
catch (error) {
lastError = error;
if (attempt < SPOTIFLAC_STATUS_MAX_ATTEMPTS) {
await delay(SPOTIFLAC_STATUS_RETRY_DELAY_MS * attempt);
if (attempt < SPOTIFLAC_NEXT_MAX_ATTEMPTS) {
await delay(SPOTIFLAC_NEXT_RETRY_DELAY_MS * attempt);
}
}
}
throw lastError instanceof Error ? lastError : new Error("SpotiFLAC status check failed");
}
async function fetchSpotiFLACStatusPayload(): Promise<SpotiFLACNextStatusResponse> {
if (activeStatusPayloadFetch) {
return activeStatusPayloadFetch;
}
activeStatusPayloadFetch = fetchStatusPayloadWithRetry(SPOTIFLAC_STATUS_URL);
try {
return await activeStatusPayloadFetch;
}
finally {
activeStatusPayloadFetch = null;
}
}
async function fetchSpotiFLACCurrentStatusPayload(): Promise<SpotiFLACNextStatusResponse> {
if (activeCurrentStatusPayloadFetch) {
return activeCurrentStatusPayloadFetch;
}
activeCurrentStatusPayloadFetch = fetchStatusPayloadWithRetry(SPOTIFLAC_CURRENT_STATUS_URL);
try {
return await activeCurrentStatusPayloadFetch;
}
finally {
activeCurrentStatusPayloadFetch = null;
}
}
async function checkSourceStatus(source: ApiSource): Promise<ApiCheckStatus> {
try {
const payload = await fetchSpotiFLACCurrentStatusPayload();
return payload[source.id] === "up" ? "online" : "offline";
}
catch (error) {
logStatusError(`[Status][${source.name}] Status check failed: ${error instanceof Error ? error.message : String(error)}`);
return "offline";
}
}
async function checkSpotiFLACNextStatuses(): Promise<Record<string, ApiCheckStatus>> {
const payload = await fetchSpotiFLACStatusPayload();
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
acc[source.id] = anyNextVariantUp(getNextSourceValues(payload, source));
return acc;
}, {});
throw lastError instanceof Error ? lastError : new Error("SpotiFLAC Next status check failed");
}
export function getApiStatusState(): ApiStatusState {
return apiStatusState;
@@ -185,19 +130,11 @@ export function subscribeApiStatus(listener: () => void): () => void {
listeners.delete(listener);
};
}
export async function checkCurrentApiStatusesOnly(): Promise<void> {
if (activeCheckCurrentOnly) {
return activeCheckCurrentOnly;
}
activeCheckCurrentOnly = (async () => {
await Promise.all(API_SOURCES.map((source) => checkApiStatus(source.id)));
})();
try {
await activeCheckCurrentOnly;
}
finally {
activeCheckCurrentOnly = null;
}
function hasSpotiFLACNextResults(): boolean {
return SPOTIFLAC_NEXT_SOURCES.some((source) => {
const status = apiStatusState.nextStatuses[source.id];
return status === "online" || status === "offline";
});
}
export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
if (activeCheckNextOnly) {
@@ -213,6 +150,10 @@ export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
},
}));
try {
setApiStatusState((current) => ({
...current,
nextStatuses: { ...current.nextStatuses },
}));
const nextStatuses = await checkSpotiFLACNextStatuses();
setApiStatusState((current) => ({
...current,
@@ -228,25 +169,17 @@ export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
nextStatuses: getSafeNextStatusesFallback(current.nextStatuses),
}));
}
finally {
activeCheckNextOnly = null;
}
})();
try {
await activeCheckNextOnly;
}
finally {
activeCheckNextOnly = null;
}
return activeCheckNextOnly;
}
export function ensureApiStatusCheckStarted(): void {
if (!activeCheckCurrentOnly && !hasCurrentResults()) {
void checkCurrentApiStatusesOnly();
}
export function ensureSpotiFLACNextStatusCheckStarted(): void {
if (!activeCheckNextOnly && !hasSpotiFLACNextResults()) {
void checkSpotiFLACNextStatusesOnly();
}
}
export function ensureSpotiFLACNextStatusCheckStarted(): void {
ensureApiStatusCheckStarted();
}
export async function checkApiStatus(sourceId: string): Promise<void> {
const source = API_SOURCES.find((item) => item.id === sourceId);
if (!source) {
+3
View File
@@ -13,6 +13,9 @@ export async function fetchSpotifyMetadata(url: string, batch: boolean = true, d
}
export async function downloadTrack(request: DownloadRequest): Promise<DownloadResponse> {
const req = new main.DownloadRequest(request);
if (request.tidal_variant !== undefined) {
(req as any).tidal_variant = request.tidal_variant;
}
if (request.use_single_genre !== undefined) {
(req as any).use_single_genre = request.use_single_genre;
}
-3
View File
@@ -40,6 +40,3 @@ export function buildClickableArtists(artists: string, artistsData?: ArtistSimpl
};
});
}
export function getClickableArtistKey(artist: ClickableArtist) {
return artist.id || artist.external_urls || artist.name;
}
-37
View File
@@ -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();
},
};
}
-9
View File
@@ -1,10 +1 @@
import { getSettings } from "@/lib/settings";
export const SPOTIFY_PREVIEW_VOLUME = 1;
export const PREVIEW_VOLUME_CHANGED_EVENT = "previewVolumeChanged";
export function getPreviewVolume(): number {
const previewVolume = getSettings().previewVolume;
if (!Number.isFinite(previewVolume)) {
return SPOTIFY_PREVIEW_VOLUME;
}
return Math.min(1, Math.max(0, previewVolume / 100));
}
+238 -614
View File
@@ -1,33 +1,15 @@
import { GetDefaults, LoadFonts as LoadFontsFromBackend, LoadSettings, SaveFonts as SaveFontsToBackend, SaveSettings as SaveToBackend, } from "../../wailsjs/go/main/App";
export type BuiltInFontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk" | "noto-sans" | "nunito-sans" | "figtree" | "raleway" | "public-sans" | "outfit" | "jetbrains-mono" | "geist-sans" | "bricolage-grotesque";
export type CustomFontFamily = `custom-${string}`;
export type FontFamily = BuiltInFontFamily | CustomFontFamily;
export interface CustomFontOption {
value: CustomFontFamily;
label: string;
fontFamily: string;
url: string;
}
export type FontOption = {
value: FontFamily;
label: string;
fontFamily: string;
url?: string;
};
import { GetDefaults, LoadSettings, SaveSettings as SaveToBackend } from "../../wailsjs/go/main/App";
export type FontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk" | "noto-sans" | "nunito-sans" | "figtree" | "raleway" | "public-sans" | "outfit" | "jetbrains-mono" | "geist-sans" | "bricolage-grotesque";
export type FolderPreset = "none" | "artist" | "album" | "year-album" | "year-artist-album" | "artist-album" | "artist-year-album" | "artist-year-nested-album" | "album-artist" | "album-artist-album" | "album-artist-year-album" | "album-artist-year-nested-album" | "year" | "year-artist" | "custom";
export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "title-album-artist" | "track-title-album-artist" | "artist-album-title" | "track-dash-title" | "disc-track-title" | "disc-track-title-artist" | "custom";
export type ExistingFileCheckMode = "filename" | "isrc";
export interface Settings {
downloadPath: string;
downloader: "auto" | "tidal" | "qobuz" | "amazon";
customTidalApi: string;
customQobuzApi: string;
linkResolver: "songstats" | "songlink";
allowResolverFallback: boolean;
theme: string;
themeMode: "auto" | "light" | "dark";
fontFamily: FontFamily;
customFonts: CustomFontOption[];
folderPreset: FolderPreset;
folderTemplate: string;
filenamePreset: FilenamePreset;
@@ -40,17 +22,16 @@ export interface Settings {
embedLyrics: boolean;
embedMaxQualityCover: boolean;
operatingSystem: "Windows" | "linux/MacOS";
tidalVariant: "tidal" | "alt";
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
qobuzQuality: "6" | "7" | "27";
amazonQuality: "16" | "24";
amazonQuality: "original";
autoOrder: "tidal-qobuz-amazon" | "tidal-amazon-qobuz" | "qobuz-tidal-amazon" | "qobuz-amazon-tidal" | "amazon-tidal-qobuz" | "amazon-qobuz-tidal" | string;
autoQuality: "16" | "24";
allowFallback: boolean;
createPlaylistFolder: boolean;
playlistOwnerFolderName: boolean;
createM3u8File: boolean;
previewVolume: number;
existingFileCheckMode: ExistingFileCheckMode;
useFirstArtistOnly: boolean;
useSingleGenre: boolean;
embedGenre: boolean;
@@ -61,105 +42,54 @@ export const FOLDER_PRESETS: Record<FolderPreset, {
label: string;
template: string;
}> = {
none: { label: "No Subfolder", template: "" },
artist: { label: "Artist", template: "{artist}" },
album: { label: "Album", template: "{album}" },
"none": { label: "No Subfolder", template: "" },
"artist": { label: "Artist", template: "{artist}" },
"album": { label: "Album", template: "{album}" },
"year-album": { label: "[Year] Album", template: "[{year}] {album}" },
"year-artist-album": {
label: "[Year] Artist - Album",
template: "[{year}] {artist} - {album}",
},
"year-artist-album": { label: "[Year] Artist - Album", template: "[{year}] {artist} - {album}" },
"artist-album": { label: "Artist / Album", template: "{artist}/{album}" },
"artist-year-album": {
label: "Artist / [Year] Album",
template: "{artist}/[{year}] {album}",
},
"artist-year-nested-album": {
label: "Artist / Year / Album",
template: "{artist}/{year}/{album}",
},
"artist-year-album": { label: "Artist / [Year] Album", template: "{artist}/[{year}] {album}" },
"artist-year-nested-album": { label: "Artist / Year / Album", template: "{artist}/{year}/{album}" },
"album-artist": { label: "Album Artist", template: "{album_artist}" },
"album-artist-album": {
label: "Album Artist / Album",
template: "{album_artist}/{album}",
},
"album-artist-year-album": {
label: "Album Artist / [Year] Album",
template: "{album_artist}/[{year}] {album}",
},
"album-artist-year-nested-album": {
label: "Album Artist / Year / Album",
template: "{album_artist}/{year}/{album}",
},
year: { label: "Year", template: "{year}" },
"album-artist-album": { label: "Album Artist / Album", template: "{album_artist}/{album}" },
"album-artist-year-album": { label: "Album Artist / [Year] Album", template: "{album_artist}/[{year}] {album}" },
"album-artist-year-nested-album": { label: "Album Artist / Year / Album", template: "{album_artist}/{year}/{album}" },
"year": { label: "Year", template: "{year}" },
"year-artist": { label: "Year / Artist", template: "{year}/{artist}" },
custom: { label: "Custom...", template: "{artist}/{album}" },
"custom": { label: "Custom...", template: "{artist}/{album}" },
};
export const FILENAME_PRESETS: Record<FilenamePreset, {
label: string;
template: string;
}> = {
title: { label: "Title", template: "{title}" },
"title": { label: "Title", template: "{title}" },
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
"artist-title": { label: "Artist - Title", template: "{artist} - {title}" },
"track-title": { label: "Track. Title", template: "{track}. {title}" },
"track-title-artist": {
label: "Track. Title - Artist",
template: "{track}. {title} - {artist}",
},
"track-artist-title": {
label: "Track. Artist - Title",
template: "{track}. {artist} - {title}",
},
"title-album-artist": {
label: "Title - Album Artist",
template: "{title} - {album_artist}",
},
"track-title-album-artist": {
label: "Track. Title - Album Artist",
template: "{track}. {title} - {album_artist}",
},
"artist-album-title": {
label: "Artist - Album - Title",
template: "{artist} - {album} - {title}",
},
"track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" },
"track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" },
"title-album-artist": { label: "Title - Album Artist", template: "{title} - {album_artist}" },
"track-title-album-artist": { label: "Track. Title - Album Artist", template: "{track}. {title} - {album_artist}" },
"artist-album-title": { label: "Artist - Album - Title", template: "{artist} - {album} - {title}" },
"track-dash-title": { label: "Track - Title", template: "{track} - {title}" },
"disc-track-title": {
label: "Disc-Track. Title",
template: "{disc}-{track}. {title}",
},
"disc-track-title-artist": {
label: "Disc-Track. Title - Artist",
template: "{disc}-{track}. {title} - {artist}",
},
custom: { label: "Custom...", template: "{title} - {artist}" },
"disc-track-title": { label: "Disc-Track. Title", template: "{disc}-{track}. {title}" },
"disc-track-title-artist": { label: "Disc-Track. Title - Artist", template: "{disc}-{track}. {title} - {artist}" },
"custom": { label: "Custom...", template: "{title} - {artist}" },
};
export const TEMPLATE_VARIABLES = [
{ key: "{title}", description: "Track title", example: "Shake It Off" },
{ key: "{artist}", description: "Track artist", example: "Taylor Swift" },
{ key: "{album}", description: "Album name", example: "1989" },
{
key: "{album_artist}",
description: "Album artist",
example: "Taylor Swift",
},
{ key: "{album_artist}", description: "Album artist", example: "Taylor Swift" },
{ key: "{track}", description: "Track number", example: "01" },
{ key: "{disc}", description: "Disc number", example: "1" },
{ key: "{year}", description: "Release year", example: "2014" },
{
key: "{date}",
description: "Release date (YYYY-MM-DD)",
example: "2014-10-27",
},
{
key: "{isrc}",
description: "Track ISRC",
example: "USUM71412345",
},
{ key: "{date}", description: "Release date (YYYY-MM-DD)", example: "2014-10-27" },
{ key: "{isrc}", description: "Track ISRC", example: "USUM71412345" },
];
function detectOS(): "Windows" | "linux/MacOS" {
const platform = window.navigator.platform.toLowerCase();
if (platform.includes("win")) {
if (platform.includes('win')) {
return "Windows";
}
return "linux/MacOS";
@@ -167,14 +97,11 @@ function detectOS(): "Windows" | "linux/MacOS" {
export const DEFAULT_SETTINGS: Settings = {
downloadPath: "",
downloader: "auto",
customTidalApi: "",
customQobuzApi: "",
linkResolver: "songlink",
allowResolverFallback: true,
theme: "yellow",
themeMode: "auto",
fontFamily: "google-sans",
customFonts: [],
folderPreset: "none",
folderTemplate: "",
filenamePreset: "title-artist",
@@ -184,506 +111,52 @@ export const DEFAULT_SETTINGS: Settings = {
embedLyrics: false,
embedMaxQualityCover: false,
operatingSystem: detectOS(),
tidalVariant: "tidal",
tidalQuality: "LOSSLESS",
qobuzQuality: "6",
amazonQuality: "16",
amazonQuality: "original",
autoOrder: "tidal-qobuz-amazon",
autoQuality: "16",
allowFallback: true,
createPlaylistFolder: true,
playlistOwnerFolderName: false,
createM3u8File: false,
previewVolume: 100,
existingFileCheckMode: "filename",
useFirstArtistOnly: false,
useSingleGenre: false,
embedGenre: false,
redownloadWithSuffix: false,
separator: "semicolon",
separator: "semicolon"
};
export const FONT_OPTIONS: FontOption[] = [
{
value: "bricolage-grotesque",
label: "Bricolage Grotesque",
fontFamily: '"Bricolage Grotesque", system-ui, sans-serif',
},
{
value: "dm-sans",
label: "DM Sans",
fontFamily: '"DM Sans", system-ui, sans-serif',
},
{
value: "figtree",
label: "Figtree",
fontFamily: '"Figtree", system-ui, sans-serif',
},
{
value: "geist-sans",
label: "Geist Sans",
fontFamily: '"Geist", system-ui, sans-serif',
},
{
value: "google-sans",
label: "Google Sans",
fontFamily: '"Google Sans", system-ui, sans-serif',
},
{
value: "inter",
label: "Inter",
fontFamily: '"Inter", system-ui, sans-serif',
},
{
value: "jetbrains-mono",
label: "JetBrains Mono",
fontFamily: '"JetBrains Mono", ui-monospace, monospace',
},
{
value: "manrope",
label: "Manrope",
fontFamily: '"Manrope", system-ui, sans-serif',
},
{
value: "noto-sans",
label: "Noto Sans",
fontFamily: '"Noto Sans", system-ui, sans-serif',
},
{
value: "nunito-sans",
label: "Nunito Sans",
fontFamily: '"Nunito Sans", system-ui, sans-serif',
},
{
value: "outfit",
label: "Outfit",
fontFamily: '"Outfit", system-ui, sans-serif',
},
{
value: "plus-jakarta-sans",
label: "Plus Jakarta Sans",
fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif',
},
{
value: "poppins",
label: "Poppins",
fontFamily: '"Poppins", system-ui, sans-serif',
},
{
value: "public-sans",
label: "Public Sans",
fontFamily: '"Public Sans", system-ui, sans-serif',
},
{
value: "raleway",
label: "Raleway",
fontFamily: '"Raleway", system-ui, sans-serif',
},
{
value: "roboto",
label: "Roboto",
fontFamily: '"Roboto", system-ui, sans-serif',
},
{
value: "space-grotesk",
label: "Space Grotesk",
fontFamily: '"Space Grotesk", system-ui, sans-serif',
},
export const FONT_OPTIONS: {
value: FontFamily;
label: string;
fontFamily: string;
}[] = [
{ value: "bricolage-grotesque", label: "Bricolage Grotesque", fontFamily: '"Bricolage Grotesque", system-ui, sans-serif' },
{ value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' },
{ value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
{ value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
{ value: "google-sans", label: "Google Sans", fontFamily: '"Google Sans", system-ui, sans-serif' },
{ value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' },
{ value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' },
{ value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' },
{ value: "noto-sans", label: "Noto Sans", fontFamily: '"Noto Sans", system-ui, sans-serif' },
{ value: "nunito-sans", label: "Nunito Sans", fontFamily: '"Nunito Sans", system-ui, sans-serif' },
{ value: "outfit", label: "Outfit", fontFamily: '"Outfit", system-ui, sans-serif' },
{ value: "plus-jakarta-sans", label: "Plus Jakarta Sans", fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif' },
{ value: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' },
{ value: "public-sans", label: "Public Sans", fontFamily: '"Public Sans", system-ui, sans-serif' },
{ value: "raleway", label: "Raleway", fontFamily: '"Raleway", system-ui, sans-serif' },
{ value: "roboto", label: "Roboto", fontFamily: '"Roboto", system-ui, sans-serif' },
{ value: "space-grotesk", label: "Space Grotesk", fontFamily: '"Space Grotesk", system-ui, sans-serif' },
];
const BUILT_IN_FONT_VALUES = new Set(FONT_OPTIONS.map((font) => font.value));
const GOOGLE_FONT_LINK_ID_PREFIX = "spotiflac-custom-font-";
const GOOGLE_FONTS_CSS_HOST = "fonts.googleapis.com";
const GOOGLE_FONTS_SPECIMEN_HOST = "fonts.google.com";
const SETTINGS_KEY = "spotiflac-settings";
let cachedSettings: Settings | null = null;
type SettingsPayload = Partial<Settings> & {
darkMode?: boolean;
[key: string]: unknown;
};
const KNOWN_SETTINGS_KEYS = Object.keys(DEFAULT_SETTINGS) as Array<keyof Settings>;
function extractGoogleFontInputUrl(input: string): string {
const trimmed = input.trim();
const hrefMatch = trimmed.match(/\bhref=["']([^"']+)["']/i);
if (hrefMatch?.[1]) {
return hrefMatch[1];
}
const importMatch = trimmed.match(/@import\s+url\(["']?([^"')]+)["']?\)/i);
if (importMatch?.[1]) {
return importMatch[1];
}
return trimmed;
}
function coerceGoogleFontUrl(rawUrl: string): string {
const trimmed = rawUrl.trim();
if (/^https?:\/\//i.test(trimmed)) {
return trimmed;
}
if (/^(fonts\.googleapis\.com|fonts\.google\.com)\//i.test(trimmed)) {
return `https://${trimmed}`;
}
return trimmed;
}
function normalizeFontLabel(label: string): string {
return label.replace(/\+/g, " ").replace(/\s+/g, " ").trim();
}
function slugifyFontLabel(label: string): string {
return label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "font";
}
function toFontFamilyCss(label: string): string {
const escapedLabel = label.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return `"${escapedLabel}", system-ui, sans-serif`;
}
function buildGoogleFontsCssUrl(label: string): string {
const url = new URL("https://fonts.googleapis.com/css2");
url.searchParams.set("family", label);
url.searchParams.set("display", "swap");
return url.toString();
}
function extractSpecimenFontLabel(parsed: URL): string {
const segments = parsed.pathname.split("/").filter(Boolean);
const specimenIndex = segments.findIndex((segment) => segment.toLowerCase() === "specimen");
const specimenName = specimenIndex >= 0 ? segments[specimenIndex + 1] : "";
return normalizeFontLabel(decodeURIComponent(specimenName || ""));
}
function normalizeGoogleFontCssUrl(rawUrl: string): string | null {
try {
const parsed = new URL(coerceGoogleFontUrl(extractGoogleFontInputUrl(rawUrl)));
if (parsed.protocol !== "https:") {
return null;
}
if (parsed.hostname === GOOGLE_FONTS_SPECIMEN_HOST) {
const label = extractSpecimenFontLabel(parsed);
return label ? buildGoogleFontsCssUrl(label) : null;
}
if (parsed.hostname !== GOOGLE_FONTS_CSS_HOST ||
(parsed.pathname !== "/css" && parsed.pathname !== "/css2")) {
return null;
}
if (parsed.searchParams.getAll("family").length === 0) {
return null;
}
if (!parsed.searchParams.has("display")) {
parsed.searchParams.set("display", "swap");
}
return parsed.toString();
}
catch {
return null;
}
}
export function parseGoogleFontUrl(rawUrl: string): CustomFontOption | null {
const normalizedUrl = normalizeGoogleFontCssUrl(rawUrl);
if (!normalizedUrl) {
return null;
}
const parsed = new URL(normalizedUrl);
const family = parsed.searchParams.getAll("family")[0];
const label = normalizeFontLabel((family || "").split(":")[0] || "");
if (!label) {
return null;
}
return {
value: `custom-${slugifyFontLabel(label)}` as CustomFontFamily,
label,
fontFamily: toFontFamilyCss(label),
url: normalizedUrl,
};
}
function normalizeCustomFonts(customFonts: unknown): CustomFontOption[] {
if (!Array.isArray(customFonts)) {
return [];
}
const normalizedFonts: CustomFontOption[] = [];
const seenValues = new Set<string>();
const seenUrls = new Set<string>();
for (const item of customFonts) {
if (!item || typeof item !== "object") {
continue;
}
const rawUrl = (item as {
url?: unknown;
}).url;
if (typeof rawUrl !== "string") {
continue;
}
const parsed = parseGoogleFontUrl(rawUrl);
if (!parsed || seenValues.has(parsed.value) || seenUrls.has(parsed.url)) {
continue;
}
seenValues.add(parsed.value);
seenUrls.add(parsed.url);
normalizedFonts.push(parsed);
}
return normalizedFonts;
}
function normalizeFontFamily(fontFamily: unknown, customFonts: CustomFontOption[]): FontFamily {
if (typeof fontFamily !== "string") {
return DEFAULT_SETTINGS.fontFamily;
}
if (BUILT_IN_FONT_VALUES.has(fontFamily as BuiltInFontFamily)) {
return fontFamily as BuiltInFontFamily;
}
const customFont = customFonts.find((font) => font.value === fontFamily);
return customFont ? customFont.value : DEFAULT_SETTINGS.fontFamily;
}
export function getFontOptions(customFonts: CustomFontOption[] = []): FontOption[] {
return [...FONT_OPTIONS, ...normalizeCustomFonts(customFonts)];
}
export function loadGoogleFontUrl(url: string, id = `${GOOGLE_FONT_LINK_ID_PREFIX}preview`): void {
const normalizedUrl = normalizeGoogleFontCssUrl(url);
if (!normalizedUrl) {
return;
}
let link = document.getElementById(id) as HTMLLinkElement | null;
if (!link) {
link = document.createElement("link");
link.id = id;
link.rel = "stylesheet";
document.head.appendChild(link);
}
if (link.href !== normalizedUrl) {
link.href = normalizedUrl;
}
}
function loadCustomFontStylesheets(customFonts: CustomFontOption[]): void {
for (const font of normalizeCustomFonts(customFonts)) {
loadGoogleFontUrl(font.url, `${GOOGLE_FONT_LINK_ID_PREFIX}${font.value}`);
}
}
export function applyFont(fontFamily: FontFamily, customFonts: CustomFontOption[] = []): void {
const fontOptions = getFontOptions(customFonts);
loadCustomFontStylesheets(customFonts);
const font = fontOptions.find((option) => option.value === fontFamily) ||
FONT_OPTIONS.find((option) => option.value === DEFAULT_SETTINGS.fontFamily);
export function applyFont(fontFamily: FontFamily): void {
const font = FONT_OPTIONS.find(f => f.value === fontFamily);
if (font) {
document.documentElement.style.setProperty("--font-sans", font.fontFamily);
document.documentElement.style.setProperty('--font-sans', font.fontFamily);
document.body.style.fontFamily = font.fontFamily;
}
}
async function persistCustomFontsInternal(customFonts: CustomFontOption[]): Promise<CustomFontOption[]> {
const normalizedFonts = normalizeCustomFonts(customFonts);
await SaveFontsToBackend(normalizedFonts as unknown as Array<Record<string, unknown>>);
if (cachedSettings) {
cachedSettings = toNormalizedSettings({
...cachedSettings,
customFonts: normalizedFonts,
});
localStorage.setItem(SETTINGS_KEY, JSON.stringify(cachedSettings));
window.dispatchEvent(new CustomEvent("settingsUpdated", { detail: cachedSettings }));
}
return normalizedFonts;
}
async function loadStoredCustomFonts(fallbackFonts?: unknown): Promise<CustomFontOption[]> {
try {
const storedFonts = await LoadFontsFromBackend();
if (storedFonts !== null) {
return normalizeCustomFonts(storedFonts);
}
}
catch (error) {
console.error("Failed to load custom fonts:", error);
}
const migratedFonts = normalizeCustomFonts(fallbackFonts);
if (migratedFonts.length > 0) {
try {
return await persistCustomFontsInternal(migratedFonts);
}
catch (error) {
console.error("Failed to migrate custom fonts:", error);
}
}
return migratedFonts;
}
export async function loadCustomFonts(): Promise<CustomFontOption[]> {
return loadStoredCustomFonts(getSettings().customFonts);
}
export async function saveCustomFonts(customFonts: CustomFontOption[]): Promise<CustomFontOption[]> {
return persistCustomFontsInternal(customFonts);
}
function keepKnownSettings(settings: SettingsPayload): SettingsPayload {
const normalized: Record<string, unknown> = {};
for (const key of KNOWN_SETTINGS_KEYS) {
if (key in settings) {
normalized[key] = settings[key];
}
}
return normalized as SettingsPayload;
}
function normalizePreviewVolume(volume: unknown): number {
const parsed = typeof volume === "number"
? volume
: typeof volume === "string"
? Number.parseFloat(volume)
: Number.NaN;
if (!Number.isFinite(parsed)) {
return DEFAULT_SETTINGS.previewVolume;
}
return Math.min(100, Math.max(0, Math.round(parsed)));
}
function normalizeCustomTidalApi(value: unknown): string {
return typeof value === "string"
? value.trim().replace(/\/+$/g, "")
: "";
}
export function hasConfiguredCustomTidalApi(value: unknown): boolean {
return normalizeCustomTidalApi(value).startsWith("https://");
}
function normalizeCustomQobuzApi(value: unknown): string {
return typeof value === "string"
? value.trim().replace(/\/+$/g, "")
: "";
}
export function hasConfiguredCustomQobuzApi(value: unknown): boolean {
return normalizeCustomQobuzApi(value).startsWith("https://");
}
export function sanitizeAutoOrder(order: unknown): string {
const allowedServices = new Set(["tidal", "qobuz", "amazon"]);
const fallbackOrder = "tidal-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): Settings["downloader"] {
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
if (normalized === "tidal" || 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 = "16";
}
if (normalized.amazonQuality !== "16" && normalized.amazonQuality !== "24") {
normalized.amazonQuality = "16";
}
if (!("autoOrder" in normalized)) {
normalized.autoOrder = DEFAULT_SETTINGS.autoOrder;
}
if (!("autoQuality" in normalized)) {
normalized.autoQuality = "16";
}
normalized.customTidalApi = normalizeCustomTidalApi(normalized.customTidalApi);
normalized.customQobuzApi = normalizeCustomQobuzApi(normalized.customQobuzApi);
normalized.downloader = normalizeDownloader(normalized.downloader);
normalized.autoOrder = sanitizeAutoOrder(normalized.autoOrder);
if (!("allowFallback" in normalized)) {
normalized.allowFallback = true;
}
if (!("linkResolver" in normalized)) {
normalized.linkResolver = "songlink";
}
if (!("allowResolverFallback" in normalized)) {
normalized.allowResolverFallback = true;
}
if (!("createPlaylistFolder" in normalized)) {
normalized.createPlaylistFolder = true;
}
if (!("playlistOwnerFolderName" in normalized)) {
normalized.playlistOwnerFolderName = false;
}
if (!("createM3u8File" in normalized)) {
normalized.createM3u8File = false;
}
normalized.previewVolume = normalizePreviewVolume(normalized.previewVolume);
normalized.existingFileCheckMode = normalizeExistingFileCheckMode(normalized.existingFileCheckMode);
if (!("useFirstArtistOnly" in normalized)) {
normalized.useFirstArtistOnly = false;
}
if (!("useSingleGenre" in normalized)) {
normalized.useSingleGenre = false;
}
if (!("embedGenre" in normalized)) {
normalized.embedGenre = false;
}
if (!("separator" in normalized)) {
normalized.separator = "semicolon";
}
if (!("redownloadWithSuffix" in normalized)) {
normalized.redownloadWithSuffix = false;
}
normalized.operatingSystem = detectOS();
const normalizedCustomFonts = normalizeCustomFonts(normalized.customFonts);
normalized.customFonts = normalizedCustomFonts;
normalized.fontFamily = normalizeFontFamily(normalized.fontFamily, normalizedCustomFonts);
return normalized;
}
function toNormalizedSettings(settings: SettingsPayload): Settings {
return {
...DEFAULT_SETTINGS,
...keepKnownSettings(normalizeSettingsPayload(settings)),
} as Settings;
}
async function persistSettingsInternal(settings: Settings, notify = true): Promise<void> {
cachedSettings = settings;
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
const settingsForBackend = { ...settings } as Record<string, unknown>;
delete settingsForBackend.customFonts;
await SaveToBackend(settingsForBackend);
if (notify) {
window.dispatchEvent(new CustomEvent("settingsUpdated", { detail: settings }));
}
}
async function fetchDefaultPath(): Promise<string> {
try {
const data = await GetDefaults();
@@ -694,11 +167,90 @@ async function fetchDefaultPath(): Promise<string> {
return "";
}
}
const SETTINGS_KEY = "spotiflac-settings";
let cachedSettings: Settings | null = null;
function getSettingsFromLocalStorage(): Settings {
try {
const stored = localStorage.getItem(SETTINGS_KEY);
if (stored) {
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) {
@@ -707,25 +259,108 @@ function getSettingsFromLocalStorage(): Settings {
return DEFAULT_SETTINGS;
}
export function getSettings(): Settings {
if (cachedSettings) {
if (cachedSettings)
return cachedSettings;
}
return getSettingsFromLocalStorage();
}
export async function loadSettings(): Promise<Settings> {
try {
const backendSettings = await LoadSettings();
if (backendSettings) {
const parsed = backendSettings as SettingsPayload;
const customFonts = await loadStoredCustomFonts(parsed.customFonts);
cachedSettings = toNormalizedSettings({
...parsed,
customFonts,
});
if ("customFonts" in parsed) {
await persistSettingsInternal(cachedSettings, false);
const parsed = backendSettings as any;
if ('darkMode' in parsed && !('themeMode' in parsed)) {
parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
delete parsed.darkMode;
}
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) {
@@ -733,19 +368,12 @@ export async function loadSettings(): Promise<Settings> {
}
const local = getSettingsFromLocalStorage();
try {
const customFonts = await loadStoredCustomFonts(local.customFonts);
const localWithFonts = toNormalizedSettings({
...local,
customFonts,
});
await persistSettingsInternal(localWithFonts, false);
cachedSettings = localWithFonts;
return localWithFonts;
await SaveToBackend(local as any);
cachedSettings = local;
}
catch (error) {
console.error("Failed to migrate settings to backend:", error);
}
cachedSettings = local;
return local;
}
export interface TemplateData {
@@ -761,9 +389,8 @@ export interface TemplateData {
playlist?: string;
}
export function parseTemplate(template: string, data: TemplateData): string {
if (!template) {
if (!template)
return "";
}
let result = template;
result = result.replace(/\{title\}/g, data.title || "Unknown Title");
result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist");
@@ -787,8 +414,10 @@ export async function getSettingsWithDefaults(): Promise<Settings> {
}
export async function saveSettings(settings: Settings): Promise<void> {
try {
const normalizedSettings = toNormalizedSettings(settings as SettingsPayload);
await persistSettingsInternal(normalizedSettings);
cachedSettings = settings;
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
await SaveToBackend(settings as any);
window.dispatchEvent(new CustomEvent('settingsUpdated', { detail: settings }));
}
catch (error) {
console.error("Failed to save settings:", error);
@@ -802,12 +431,7 @@ export async function updateSettings(partial: Partial<Settings>): Promise<Settin
}
export async function resetToDefaultSettings(): Promise<Settings> {
const defaultPath = await fetchDefaultPath();
const customFonts = await loadCustomFonts();
const defaultSettings = {
...DEFAULT_SETTINGS,
downloadPath: defaultPath,
customFonts,
};
const defaultSettings = { ...DEFAULT_SETTINGS, downloadPath: defaultPath };
await saveSettings(defaultSettings);
return defaultSettings;
}
+2 -5
View File
@@ -1,12 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { MotionConfig } from "motion/react";
import "./index.css";
import App from "./App.tsx";
import { Toaster } from "@/components/ui/sonner";
createRoot(document.getElementById("root")!).render(<StrictMode>
<MotionConfig reducedMotion="user">
<App />
<Toaster position="bottom-left" duration={1000}/>
</MotionConfig>
<App />
<Toaster position="bottom-left" duration={1000}/>
</StrictMode>);
+1 -4
View File
@@ -40,7 +40,6 @@ export interface AlbumInfo {
release_date: string;
artists: string;
images: string;
is_explicit?: boolean;
upc?: string;
batch?: string;
}
@@ -94,7 +93,6 @@ export interface DiscographyAlbum {
artists: string;
images: string;
external_urls: string;
is_explicit?: boolean;
}
export interface ArtistDiscographyResponse {
artist_info: ArtistInfo;
@@ -122,7 +120,7 @@ export interface DownloadRequest {
release_date?: string;
cover_url?: string;
tidal_api_url?: string;
qobuz_api_url?: string;
tidal_variant?: "tidal" | "alt";
output_dir?: string;
audio_format?: string;
folder_name?: string;
@@ -154,7 +152,6 @@ export interface DownloadResponse {
file?: string;
error?: string;
already_exists?: boolean;
cancelled?: boolean;
item_id?: string;
}
export interface HealthResponse {
+142
View File
@@ -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>;
+279
View File
@@ -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);
}
+1 -3
View File
@@ -3,21 +3,19 @@ module github.com/afkarxyz/SpotiFLAC
go 1.26
require (
github.com/Eyevinn/mp4ff v0.52.0
github.com/bogem/id3v2/v2 v2.1.4
github.com/go-flac/flacpicture v0.3.0
github.com/go-flac/flacvorbis v0.2.0
github.com/go-flac/go-flac v1.0.0
github.com/pquerna/otp v1.5.0
github.com/ulikunitz/xz v0.5.15
github.com/wailsapp/wails/v2 v2.12.0
github.com/wailsapp/wails/v2 v2.11.0
go.etcd.io/bbolt v1.4.3
golang.org/x/image v0.12.0
golang.org/x/text v0.31.0
)
require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
+2 -8
View File
@@ -1,7 +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/Eyevinn/mp4ff v0.52.0 h1:QJUi2PtROeZGkcumbX7f4/91Jz6dlhjeKzpwSdCoYG8=
github.com/Eyevinn/mp4ff v0.52.0/go.mod h1:LKZAf3K+OtWYdzlvte8uafD/e3g2aK2WcsgVohvjccU=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
@@ -19,8 +15,6 @@ github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8=
github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -79,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/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
+1 -1
View File
@@ -12,7 +12,7 @@
},
"info": {
"productName": "SpotiFLAC",
"productVersion": "7.1.8",
"productVersion": "7.1.5",
"copyright": "© 2026 afkarxyz"
},
"wailsjsdir": "./frontend",