Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8da9aed4b | |||
| 954cfe9d4f | |||
| 31e9ecac35 | |||
| 0c3a7b70af | |||
| 254022d81d | |||
| b3ebef5ab9 |
+2
-1
@@ -1 +1,2 @@
|
|||||||
ko_fi: afkarxyz
|
ko_fi: afkarxyz
|
||||||
|
patreon: afkarxyz
|
||||||
@@ -24,14 +24,16 @@ Get Spotify tracks in true Lossless from Tidal, Qobuz, Amazon Music, Deezer & Ap
|
|||||||
|
|
||||||
Download Spotify Tracks, Albums, Playlists & Discography as MP3/OGG/Opus.
|
Download Spotify Tracks, Albums, Playlists & Discography as MP3/OGG/Opus.
|
||||||
|
|
||||||
|
## Related projects
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
>
|
||||||
|
> Related projects are maintained by the community and are not affiliated with the core SpotiFLAC desktop build.
|
||||||
|
|
||||||
### [SpotiFLAC (Mobile)](https://github.com/zarzet/SpotiFLAC-Mobile)
|
### [SpotiFLAC (Mobile)](https://github.com/zarzet/SpotiFLAC-Mobile)
|
||||||
|
|
||||||
SpotiFLAC for Android & iOS — maintained by [@zarzet](https://github.com/zarzet)
|
SpotiFLAC for Android & iOS — maintained by [@zarzet](https://github.com/zarzet)
|
||||||
|
|
||||||
### [SpotiFLAC (CLI)](https://github.com/Nizarberyan/SpotiFLAC)
|
|
||||||
|
|
||||||
SpotiFLAC for command-line environments — maintained by [@Nizarberyan](https://github.com/Nizarberyan)
|
|
||||||
|
|
||||||
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
||||||
|
|
||||||
SpotiFLAC Python library for SpotiFLAC integration — maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu)
|
SpotiFLAC Python library for SpotiFLAC integration — maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu)
|
||||||
@@ -108,7 +110,7 @@ The software is provided "as is", without warranty of any kind. The author assum
|
|||||||
|
|
||||||
## API Credits
|
## API Credits
|
||||||
|
|
||||||
[MusicBrainz](https://musicbrainz.org) · [LRCLIB](https://lrclib.net) · [Songlink/Odesli](https://song.link) · [hifi-api](https://github.com/binimum/hifi-api) · [dabmusic.xyz](https://dabmusic.xyz) · [musicdl.me](https://musicdl.me)
|
[MusicBrainz](https://musicbrainz.org) · [LRCLIB](https://lrclib.net) · [Songlink/Odesli](https://song.link) · [Songstats](https://songstats.com) · [hifi-api](https://github.com/binimum/hifi-api) · [Qobuz-DL](https://github.com/QobuzDL/Qobuz-DL)
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -33,12 +33,41 @@ type CurrentIPInfo struct {
|
|||||||
Source string `json:"source,omitempty"`
|
Source string `json:"source,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type APIStatusTargetResult struct {
|
||||||
|
Target string `json:"target"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Online bool `json:"online"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIStatusReport struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Online bool `json:"online"`
|
||||||
|
RequireAll bool `json:"require_all"`
|
||||||
|
Details []APIStatusTargetResult `json:"details"`
|
||||||
|
}
|
||||||
|
|
||||||
const checkOperationTimeout = 10 * time.Second
|
const checkOperationTimeout = 10 * time.Second
|
||||||
|
|
||||||
func NewApp() *App {
|
func NewApp() *App {
|
||||||
return &App{}
|
return &App{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) LogStatusConsole(level string, message string) {
|
||||||
|
normalizedLevel := strings.ToLower(strings.TrimSpace(level))
|
||||||
|
if normalizedLevel == "" {
|
||||||
|
normalizedLevel = "info"
|
||||||
|
}
|
||||||
|
|
||||||
|
line := fmt.Sprintf("[%s] [%s] %s\n", time.Now().Format("15:04:05"), normalizedLevel, strings.TrimSpace(message))
|
||||||
|
switch normalizedLevel {
|
||||||
|
case "error":
|
||||||
|
_, _ = fmt.Fprint(os.Stderr, line)
|
||||||
|
default:
|
||||||
|
fmt.Print(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type timedResult[T any] struct {
|
type timedResult[T any] struct {
|
||||||
value T
|
value T
|
||||||
err error
|
err error
|
||||||
@@ -276,11 +305,12 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
if err := backend.InitProviderPriorityDB(); err != nil {
|
if err := backend.InitProviderPriorityDB(); err != nil {
|
||||||
fmt.Printf("Failed to init provider priority DB: %v\n", err)
|
fmt.Printf("Failed to init provider priority DB: %v\n", err)
|
||||||
}
|
}
|
||||||
go func() {
|
if err := backend.CleanupLegacyTidalPublicAPIState(); err != nil {
|
||||||
if err := backend.PrimeTidalAPIList(); err != nil {
|
fmt.Printf("Failed to clean legacy Tidal API cache: %v\n", err)
|
||||||
fmt.Printf("Failed to prime Tidal API list: %v\n", err)
|
}
|
||||||
}
|
if err := backend.SanitizePersistedConfigSettings(); err != nil {
|
||||||
}()
|
fmt.Printf("Failed to sanitize persisted config settings: %v\n", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) shutdown(ctx context.Context) {
|
func (a *App) shutdown(ctx context.Context) {
|
||||||
@@ -307,6 +337,7 @@ type DownloadRequest struct {
|
|||||||
ReleaseDate string `json:"release_date,omitempty"`
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
CoverURL string `json:"cover_url,omitempty"`
|
CoverURL string `json:"cover_url,omitempty"`
|
||||||
TidalAPIURL string `json:"tidal_api_url,omitempty"`
|
TidalAPIURL string `json:"tidal_api_url,omitempty"`
|
||||||
|
QobuzAPIURL string `json:"qobuz_api_url,omitempty"`
|
||||||
OutputDir string `json:"output_dir,omitempty"`
|
OutputDir string `json:"output_dir,omitempty"`
|
||||||
AudioFormat string `json:"audio_format,omitempty"`
|
AudioFormat string `json:"audio_format,omitempty"`
|
||||||
FilenameFormat string `json:"filename_format,omitempty"`
|
FilenameFormat string `json:"filename_format,omitempty"`
|
||||||
@@ -342,6 +373,7 @@ type DownloadResponse struct {
|
|||||||
File string `json:"file,omitempty"`
|
File string `json:"file,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||||
|
Cancelled bool `json:"cancelled,omitempty"`
|
||||||
ItemID string `json:"item_id,omitempty"`
|
ItemID string `json:"item_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,6 +560,20 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
backend.StartDownloadItem(itemID)
|
backend.StartDownloadItem(itemID)
|
||||||
defer backend.SetDownloading(false)
|
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 := ""
|
spotifyURL := ""
|
||||||
if req.SpotifyID != "" {
|
if req.SpotifyID != "" {
|
||||||
spotifyURL = fmt.Sprintf("https://open.spotify.com/track/%s", req.SpotifyID)
|
spotifyURL = fmt.Sprintf("https://open.spotify.com/track/%s", req.SpotifyID)
|
||||||
@@ -662,20 +708,11 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "tidal":
|
case "tidal":
|
||||||
if req.TidalAPIURL == "" || req.TidalAPIURL == "auto" {
|
downloader := backend.NewTidalDownloader(req.TidalAPIURL)
|
||||||
downloader := backend.NewTidalDownloader("")
|
if req.ServiceURL != "" {
|
||||||
if req.ServiceURL != "" {
|
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||||
filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
|
||||||
} else {
|
|
||||||
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
downloader := backend.NewTidalDownloader(req.TidalAPIURL)
|
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||||
if req.ServiceURL != "" {
|
|
||||||
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
|
||||||
} else {
|
|
||||||
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case "qobuz":
|
case "qobuz":
|
||||||
@@ -686,6 +723,9 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
isrc = <-isrcChan
|
isrc = <-isrcChan
|
||||||
}
|
}
|
||||||
downloader := backend.NewQobuzDownloader()
|
downloader := backend.NewQobuzDownloader()
|
||||||
|
if strings.HasPrefix(strings.TrimRight(strings.TrimSpace(req.QobuzAPIURL), "/"), "https://") {
|
||||||
|
downloader.SetCustomAPIURL(req.QobuzAPIURL)
|
||||||
|
}
|
||||||
quality := req.AudioFormat
|
quality := req.AudioFormat
|
||||||
if quality == "" {
|
if quality == "" {
|
||||||
quality = "6"
|
quality = "6"
|
||||||
@@ -700,6 +740,22 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
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))
|
backend.FailDownloadItem(itemID, fmt.Sprintf("Download failed: %v", err))
|
||||||
|
|
||||||
if filename != "" && !strings.HasPrefix(filename, "EXISTS:") {
|
if filename != "" && !strings.HasPrefix(filename, "EXISTS:") {
|
||||||
@@ -725,6 +781,20 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
filename = strings.TrimPrefix(filename, "EXISTS:")
|
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 {
|
if !alreadyExists {
|
||||||
validated, validationErr := backend.ValidateDownloadedTrackDuration(filename, req.Duration)
|
validated, validationErr := backend.ValidateDownloadedTrackDuration(filename, req.Duration)
|
||||||
if validationErr != nil {
|
if validationErr != nil {
|
||||||
@@ -914,6 +984,10 @@ func (a *App) CancelAllQueuedItems() {
|
|||||||
backend.CancelAllQueuedItems()
|
backend.CancelAllQueuedItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) ForceStopDownloads() {
|
||||||
|
backend.ForceStopActiveDownloads()
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) ExportFailedDownloads() (string, error) {
|
func (a *App) ExportFailedDownloads() (string, error) {
|
||||||
queueInfo := backend.GetDownloadQueue()
|
queueInfo := backend.GetDownloadQueue()
|
||||||
var failedItems []string
|
var failedItems []string
|
||||||
@@ -986,15 +1060,7 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
|
|||||||
isOnline, err := runWithTimeout(checkOperationTimeout, func() (bool, error) {
|
isOnline, err := runWithTimeout(checkOperationTimeout, func() (bool, error) {
|
||||||
switch apiType {
|
switch apiType {
|
||||||
case "tidal":
|
case "tidal":
|
||||||
if checkGroupedAPIStatus("tidal", buildTidalStatusCheckURLs(apiURL)) {
|
return checkGroupedAPIStatus("tidal", buildTidalStatusCheckURLs(apiURL)), nil
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(apiURL) == "" {
|
|
||||||
if _, refreshErr := backend.RefreshTidalAPIList(true); refreshErr == nil && checkGroupedAPIStatus("tidal", buildTidalStatusCheckURLs("")) {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
case "qobuz", "qbz":
|
case "qobuz", "qbz":
|
||||||
return checkGroupedAPIStatus("qobuz", buildQobuzStatusCheckURLs(apiURL)), nil
|
return checkGroupedAPIStatus("qobuz", buildQobuzStatusCheckURLs(apiURL)), nil
|
||||||
case "amazon":
|
case "amazon":
|
||||||
@@ -1022,6 +1088,39 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
|
|||||||
return isOnline
|
return isOnline
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) CheckAPIStatusReport(apiType string, apiURL string) APIStatusReport {
|
||||||
|
report, err := runWithTimeout(checkOperationTimeout, func() (APIStatusReport, error) {
|
||||||
|
switch apiType {
|
||||||
|
case "tidal":
|
||||||
|
return buildGroupedAPIStatusReport("tidal", buildTidalStatusCheckURLs(apiURL), false), nil
|
||||||
|
case "qobuz", "qbz":
|
||||||
|
return buildGroupedAPIStatusReport("qobuz", buildQobuzStatusCheckURLs(apiURL), false), nil
|
||||||
|
case "amazon":
|
||||||
|
return buildGroupedAPIStatusReport("amazon", buildAmazonStatusCheckURLs(apiURL), false), nil
|
||||||
|
case "lrclib":
|
||||||
|
return buildGroupedAPIStatusReport("lrclib", buildLRCLIBStatusCheckURLs(apiURL), false), nil
|
||||||
|
case "musicbrainz":
|
||||||
|
return buildGroupedAPIStatusReport("musicbrainz", buildMusicBrainzStatusCheckURLs(apiURL), false), nil
|
||||||
|
default:
|
||||||
|
return buildGroupedAPIStatusReport(apiType, []string{strings.TrimSpace(apiURL)}, false), nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return APIStatusReport{
|
||||||
|
Type: apiType,
|
||||||
|
Online: false,
|
||||||
|
RequireAll: apiType == "qobuz" || apiType == "qbz",
|
||||||
|
Details: []APIStatusTargetResult{{
|
||||||
|
Target: strings.TrimSpace(apiURL),
|
||||||
|
Label: describeAPIStatusTarget(apiType, apiURL),
|
||||||
|
Online: false,
|
||||||
|
Message: err.Error(),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) CheckCustomTidalAPI(apiURL string) bool {
|
func (a *App) CheckCustomTidalAPI(apiURL string) bool {
|
||||||
type tidalProbeResponse struct {
|
type tidalProbeResponse struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
@@ -1106,48 +1205,74 @@ func (a *App) CheckCustomTidalAPI(apiURL string) bool {
|
|||||||
return false
|
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 {
|
func buildTidalStatusCheckURLs(apiURL string) []string {
|
||||||
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||||
if apiURL != "" {
|
if apiURL == "" {
|
||||||
return []string{fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL)}
|
return nil
|
||||||
}
|
}
|
||||||
|
return []string{fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL)}
|
||||||
apis, err := backend.GetRotatedTidalAPIList()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Warning: failed to load rotated Tidal API list for status check: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
urls := make([]string, 0, len(apis))
|
|
||||||
for _, baseURL := range apis {
|
|
||||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
|
||||||
if baseURL == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
urls = append(urls, fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", baseURL))
|
|
||||||
}
|
|
||||||
|
|
||||||
return urls
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildQobuzStatusCheckURLs(apiURL string) []string {
|
func buildQobuzStatusCheckURLs(apiURL string) []string {
|
||||||
if trimmed := strings.TrimSpace(apiURL); trimmed != "" {
|
if trimmed := strings.TrimSpace(apiURL); trimmed != "" {
|
||||||
return []string{buildQobuzStatusCheckURL(trimmed)}
|
return []string{trimmed}
|
||||||
}
|
}
|
||||||
|
|
||||||
bases := backend.GetQobuzStreamAPIBaseURLs()
|
return backend.GetQobuzDownloadProviderURLs()
|
||||||
urls := make([]string, 0, len(bases)+1)
|
|
||||||
for _, baseURL := range bases {
|
|
||||||
urls = append(urls, buildQobuzStatusCheckURL(baseURL))
|
|
||||||
}
|
|
||||||
if musicDLURL := strings.TrimSpace(backend.GetQobuzMusicDLDownloadAPIURL()); musicDLURL != "" {
|
|
||||||
urls = append(urls, musicDLURL)
|
|
||||||
}
|
|
||||||
return urls
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildQobuzStatusCheckURL(apiBase string) string {
|
|
||||||
apiBase = strings.TrimSpace(apiBase)
|
|
||||||
return fmt.Sprintf("%s360735657&quality=27", apiBase)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildAmazonStatusCheckURLs(apiURL string) []string {
|
func buildAmazonStatusCheckURLs(apiURL string) []string {
|
||||||
@@ -1213,10 +1338,183 @@ func checkGroupedAPIStatus(apiType string, checkURLs []string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildGroupedAPIStatusReport(apiType string, checkURLs []string, requireAll bool) APIStatusReport {
|
||||||
|
filtered := make([]string, 0, len(checkURLs))
|
||||||
|
for _, rawURL := range checkURLs {
|
||||||
|
target := strings.TrimSpace(rawURL)
|
||||||
|
if target == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
report := APIStatusReport{
|
||||||
|
Type: apiType,
|
||||||
|
Online: !requireAll,
|
||||||
|
RequireAll: requireAll,
|
||||||
|
Details: make([]APIStatusTargetResult, len(filtered)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
report.Online = false
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for index, target := range filtered {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int, rawTarget string) {
|
||||||
|
defer wg.Done()
|
||||||
|
report.Details[idx] = checkSingleAPIStatusDetailed(apiType, rawTarget)
|
||||||
|
}(index, target)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if requireAll {
|
||||||
|
report.Online = true
|
||||||
|
for _, detail := range report.Details {
|
||||||
|
if !detail.Online {
|
||||||
|
report.Online = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
report.Online = false
|
||||||
|
for _, detail := range report.Details {
|
||||||
|
if detail.Online {
|
||||||
|
report.Online = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
|
func describeAPIStatusTarget(apiType string, checkURL string) string {
|
||||||
|
trimmedType := strings.TrimSpace(strings.ToLower(apiType))
|
||||||
|
trimmedURL := strings.TrimSpace(checkURL)
|
||||||
|
|
||||||
|
if trimmedType == "qobuz" || trimmedType == "qbz" {
|
||||||
|
switch {
|
||||||
|
case backend.IsQobuzWJHEProviderURL(trimmedURL):
|
||||||
|
return "WJHE"
|
||||||
|
case backend.IsQobuzMusicDLProviderURL(trimmedURL):
|
||||||
|
return "MusicDL"
|
||||||
|
case backend.IsQobuzGDStudioProviderURL(trimmedURL):
|
||||||
|
parsed, err := url.Parse(trimmedURL)
|
||||||
|
if err == nil {
|
||||||
|
host := strings.ToLower(strings.TrimSpace(parsed.Host))
|
||||||
|
switch {
|
||||||
|
case strings.Contains(host, "xyz"):
|
||||||
|
return "GDStudio XYZ"
|
||||||
|
case strings.Contains(host, "org"):
|
||||||
|
return "GDStudio ORG"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "GDStudio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmedURL != "" {
|
||||||
|
if parsed, err := url.Parse(trimmedURL); err == nil && strings.TrimSpace(parsed.Host) != "" {
|
||||||
|
return strings.TrimSpace(parsed.Host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmedType == "" {
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.ToUpper(trimmedType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkSingleAPIStatusDetailed(apiType string, checkURL string) APIStatusTargetResult {
|
||||||
|
result := APIStatusTargetResult{
|
||||||
|
Target: strings.TrimSpace(checkURL),
|
||||||
|
Label: describeAPIStatusTarget(apiType, checkURL),
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 4 * time.Second}
|
||||||
|
trimmedType := strings.TrimSpace(strings.ToLower(apiType))
|
||||||
|
|
||||||
|
if trimmedType == "qobuz" || trimmedType == "qbz" {
|
||||||
|
var err error
|
||||||
|
switch {
|
||||||
|
case backend.IsQobuzWJHEProviderURL(checkURL):
|
||||||
|
err = backend.CheckQobuzWJHEStatusDetailed(client)
|
||||||
|
case backend.IsQobuzMusicDLProviderURL(checkURL):
|
||||||
|
err = backend.CheckQobuzMusicDLStatusDetailed(client)
|
||||||
|
case backend.IsQobuzGDStudioProviderURL(checkURL):
|
||||||
|
err = backend.CheckQobuzGDStudioAPIStatusDetailed(client, checkURL)
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("unknown qobuz provider url: %s", strings.TrimSpace(checkURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
result.Message = err.Error()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Online = true
|
||||||
|
result.Message = "stream URL resolved"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := backend.NewRequestWithDefaultHeaders(http.MethodGet, checkURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
result.Message = fmt.Sprintf("failed to create request: %v", err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
result.Message = fmt.Sprintf("request failed: %v", err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
if err != nil {
|
||||||
|
result.Message = fmt.Sprintf("failed to read response: %v", err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
switch trimmedType {
|
||||||
|
case "amazon":
|
||||||
|
if resp.StatusCode == http.StatusOK && strings.Contains(string(body), `"amazonMusic":"up"`) {
|
||||||
|
result.Online = true
|
||||||
|
result.Message = `amazonMusic="up"`
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
result.Message = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, previewResponseBody(body, 160))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result.Message = `amazonMusic was not reported as "up"`
|
||||||
|
return result
|
||||||
|
default:
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
result.Online = true
|
||||||
|
result.Message = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result.Message = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, previewResponseBody(body, 160))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func checkSingleAPIStatus(apiType string, checkURL string) bool {
|
func checkSingleAPIStatus(apiType string, checkURL string) bool {
|
||||||
client := &http.Client{Timeout: 4 * time.Second}
|
client := &http.Client{Timeout: 4 * time.Second}
|
||||||
if (apiType == "qobuz" || apiType == "qbz") && strings.EqualFold(strings.TrimSpace(checkURL), strings.TrimSpace(backend.GetQobuzMusicDLDownloadAPIURL())) {
|
if apiType == "qobuz" || apiType == "qbz" {
|
||||||
return backend.CheckQobuzMusicDLStatus(client)
|
switch {
|
||||||
|
case backend.IsQobuzWJHEProviderURL(checkURL):
|
||||||
|
return backend.CheckQobuzWJHEStatus(client)
|
||||||
|
case backend.IsQobuzMusicDLProviderURL(checkURL):
|
||||||
|
return backend.CheckQobuzMusicDLStatus(client)
|
||||||
|
case backend.IsQobuzGDStudioProviderURL(checkURL):
|
||||||
|
return backend.CheckQobuzGDStudioAPIStatus(client, checkURL)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := backend.NewRequestWithDefaultHeaders(http.MethodGet, checkURL, nil)
|
req, err := backend.NewRequestWithDefaultHeaders(http.MethodGet, checkURL, nil)
|
||||||
@@ -1719,6 +2017,28 @@ func (a *App) ReadFileMetadata(filePath string) (*backend.AudioMetadata, error)
|
|||||||
return backend.ReadAudioMetadata(filePath)
|
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 {
|
func (a *App) PreviewRenameFiles(files []string, format string) []backend.RenamePreview {
|
||||||
return backend.PreviewRename(files, format)
|
return backend.PreviewRename(files, format)
|
||||||
}
|
}
|
||||||
@@ -2045,6 +2365,7 @@ func (a *App) SaveSettings(settings map[string]interface{}) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
settings = backend.SanitizeSettingsMap(settings)
|
||||||
|
|
||||||
dir := filepath.Dir(configPath)
|
dir := filepath.Dir(configPath)
|
||||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||||
@@ -2102,7 +2423,7 @@ func (a *App) LoadSettings() (map[string]interface{}, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return settings, nil
|
return backend.SanitizeSettingsMap(settings), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) LoadFonts() ([]map[string]interface{}, error) {
|
func (a *App) LoadFonts() ([]map[string]interface{}, error) {
|
||||||
|
|||||||
+130
-180
@@ -1,9 +1,7 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/aes"
|
"bytes"
|
||||||
"crypto/cipher"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -13,7 +11,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,81 +19,6 @@ type AmazonDownloader struct {
|
|||||||
regions []string
|
regions []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type AmazonStreamResponse struct {
|
|
||||||
StreamURL string `json:"streamUrl"`
|
|
||||||
DecryptionKey string `json:"decryptionKey"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
amazonMusicDebugKeyOnce sync.Once
|
|
||||||
amazonMusicDebugKey string
|
|
||||||
amazonMusicDebugKeyErr error
|
|
||||||
)
|
|
||||||
|
|
||||||
var amazonMusicDebugKeySeedParts = [][]byte{
|
|
||||||
[]byte("spotif"),
|
|
||||||
[]byte("lac:am"),
|
|
||||||
[]byte("azon:spotbye:api:v1"),
|
|
||||||
}
|
|
||||||
|
|
||||||
var amazonMusicDebugKeyAAD = []byte{
|
|
||||||
0x61, 0x6d, 0x61, 0x7a, 0x6f, 0x6e, 0x7c, 0x73, 0x70, 0x6f, 0x74, 0x62,
|
|
||||||
0x79, 0x65, 0x7c, 0x64, 0x65, 0x62, 0x75, 0x67, 0x7c, 0x76, 0x31,
|
|
||||||
}
|
|
||||||
|
|
||||||
var amazonMusicDebugKeyNonce = []byte{
|
|
||||||
0x52, 0x1f, 0xa4, 0x9c, 0x13, 0x77, 0x5b, 0xe2, 0x81, 0x44, 0x90, 0x6d,
|
|
||||||
}
|
|
||||||
|
|
||||||
var amazonMusicDebugKeyCiphertext = []byte{
|
|
||||||
0x5b, 0xf9, 0xc1, 0x2e, 0x58, 0xf8, 0x5b, 0xc0, 0x04, 0x68, 0x7e, 0xff,
|
|
||||||
0x3d, 0xd6, 0x8b, 0xe3, 0x86, 0x49, 0x6c, 0xfd, 0xc1, 0x49, 0x0b, 0xfb,
|
|
||||||
}
|
|
||||||
|
|
||||||
var amazonMusicDebugKeyTag = []byte{
|
|
||||||
0x6c, 0x21, 0x98, 0x51, 0xf2, 0x38, 0x4b, 0x4a, 0x23, 0xe1, 0xc6, 0xd7,
|
|
||||||
0x65, 0x7f, 0xfb, 0xa1,
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAmazonMusicDebugKey() (string, error) {
|
|
||||||
amazonMusicDebugKeyOnce.Do(func() {
|
|
||||||
hasher := sha256.New()
|
|
||||||
for _, part := range amazonMusicDebugKeySeedParts {
|
|
||||||
hasher.Write(part)
|
|
||||||
}
|
|
||||||
|
|
||||||
block, err := aes.NewCipher(hasher.Sum(nil))
|
|
||||||
if err != nil {
|
|
||||||
amazonMusicDebugKeyErr = err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
gcm, err := cipher.NewGCM(block)
|
|
||||||
if err != nil {
|
|
||||||
amazonMusicDebugKeyErr = err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sealed := make([]byte, 0, len(amazonMusicDebugKeyCiphertext)+len(amazonMusicDebugKeyTag))
|
|
||||||
sealed = append(sealed, amazonMusicDebugKeyCiphertext...)
|
|
||||||
sealed = append(sealed, amazonMusicDebugKeyTag...)
|
|
||||||
|
|
||||||
plaintext, err := gcm.Open(nil, amazonMusicDebugKeyNonce, sealed, amazonMusicDebugKeyAAD)
|
|
||||||
if err != nil {
|
|
||||||
amazonMusicDebugKeyErr = err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
amazonMusicDebugKey = string(plaintext)
|
|
||||||
})
|
|
||||||
|
|
||||||
if amazonMusicDebugKeyErr != nil {
|
|
||||||
return "", amazonMusicDebugKeyErr
|
|
||||||
}
|
|
||||||
|
|
||||||
return amazonMusicDebugKey, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAmazonDownloader() *AmazonDownloader {
|
func NewAmazonDownloader() *AmazonDownloader {
|
||||||
return &AmazonDownloader{
|
return &AmazonDownloader{
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
@@ -122,7 +44,29 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
|
|||||||
return amazonURL, nil
|
return amazonURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality string) (string, error) {
|
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) {
|
||||||
|
|
||||||
asinRegex := regexp.MustCompile(`(B[0-9A-Z]{9})`)
|
asinRegex := regexp.MustCompile(`(B[0-9A-Z]{9})`)
|
||||||
asin := asinRegex.FindString(amazonURL)
|
asin := asinRegex.FindString(amazonURL)
|
||||||
@@ -130,20 +74,28 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
|
|||||||
return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL)
|
return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
apiURL := fmt.Sprintf("%s/api/track/%s", amazonMusicAPIBaseURL, asin)
|
payload, err := json.Marshal(map[string]string{
|
||||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
|
"id": asin,
|
||||||
|
"quality": amazonCommunityNormalizeQuality(quality),
|
||||||
|
"country": "US",
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
debugKey, err := getAmazonMusicDebugKey()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to decrypt Amazon debug key: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("X-Debug-Key", debugKey)
|
|
||||||
|
|
||||||
fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin)
|
fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin)
|
||||||
resp, err := a.client.Do(req)
|
resp, err := 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
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -158,29 +110,43 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
var apiResp AmazonStreamResponse
|
var apiResp amazonCommunityResponse
|
||||||
if err := json.Unmarshal(bodyBytes, &apiResp); err != nil {
|
if err := json.Unmarshal(bodyBytes, &apiResp); err != nil {
|
||||||
return "", fmt.Errorf("failed to decode response: %w", err)
|
return "", fmt.Errorf("failed to decode response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if apiResp.StreamURL == "" {
|
streamURL := strings.TrimSpace(apiResp.StreamURL)
|
||||||
|
if streamURL == "" {
|
||||||
|
streamURL = strings.TrimSpace(apiResp.URL)
|
||||||
|
}
|
||||||
|
if streamURL == "" {
|
||||||
return "", fmt.Errorf("no stream URL found in response")
|
return "", fmt.Errorf("no stream URL found in response")
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadURL := apiResp.StreamURL
|
keySpecs := apiResp.KeySpecs
|
||||||
fileName := fmt.Sprintf("%s.m4a", asin)
|
if len(keySpecs) == 0 {
|
||||||
filePath := filepath.Join(outputDir, fileName)
|
if key := strings.TrimSpace(apiResp.Key); key != "" {
|
||||||
|
keySpecs = []string{key}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
out, err := os.Create(filePath)
|
encryptedPath := filepath.Join(outputDir, fmt.Sprintf("%s.encrypted.mp4", asin))
|
||||||
|
out, err := os.Create(encryptedPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer out.Close()
|
defer func() {
|
||||||
|
out.Close()
|
||||||
|
os.Remove(encryptedPath)
|
||||||
|
}()
|
||||||
|
|
||||||
dlReq, err := NewRequestWithDefaultHeaders(http.MethodGet, downloadURL, nil)
|
dlReq, err := NewRequestWithDefaultHeaders(http.MethodGet, streamURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
if captcha := strings.TrimSpace(apiResp.Captcha); captcha != "" {
|
||||||
|
dlReq.Header.Set("x-captcha-token", captcha)
|
||||||
|
}
|
||||||
|
|
||||||
dlResp, err := a.client.Do(dlReq)
|
dlResp, err := a.client.Do(dlReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -188,101 +154,85 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
|
|||||||
}
|
}
|
||||||
defer dlResp.Body.Close()
|
defer dlResp.Body.Close()
|
||||||
|
|
||||||
fmt.Printf("Downloading track: %s\n", fileName)
|
fmt.Printf("Downloading track: %s\n", asin)
|
||||||
pw := NewProgressWriter(out)
|
pw := NewProgressWriter(out)
|
||||||
_, err = io.Copy(pw, dlResp.Body)
|
if _, err = io.Copy(pw, dlResp.Body); err != nil {
|
||||||
if err != nil {
|
|
||||||
out.Close()
|
|
||||||
os.Remove(filePath)
|
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
out.Close()
|
||||||
|
|
||||||
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
||||||
|
|
||||||
if apiResp.DecryptionKey != "" {
|
remuxInput := encryptedPath
|
||||||
|
if len(keySpecs) > 0 {
|
||||||
fmt.Printf("Decrypting file...\n")
|
fmt.Printf("Decrypting file...\n")
|
||||||
|
decryptedPath := filepath.Join(outputDir, fmt.Sprintf("%s.decrypted.mp4", asin))
|
||||||
ffprobePath, err := GetFFprobePath()
|
if err := decryptWithMP4FF(keySpecs, encryptedPath, decryptedPath); err != nil {
|
||||||
var codec string
|
return "", err
|
||||||
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)
|
||||||
targetExt := ".m4a"
|
remuxInput = decryptedPath
|
||||||
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")
|
fmt.Println("Decryption successful")
|
||||||
}
|
}
|
||||||
|
|
||||||
return filePath, nil
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality string) (string, error) {
|
func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality string) (string, error) {
|
||||||
return a.DownloadFromAfkarXYZ(amazonURL, outputDir, quality)
|
return a.downloadFromCommunity(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) {
|
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) {
|
||||||
@@ -339,7 +289,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
|
|||||||
fmt.Println("Fetching MusicBrainz metadata...")
|
fmt.Println("Fetching MusicBrainz metadata...")
|
||||||
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
|
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
|
||||||
res.Metadata = fetchedMeta
|
res.Metadata = fetchedMeta
|
||||||
fmt.Println("✓ MusicBrainz metadata fetched")
|
fmt.Println("MusicBrainz metadata fetched")
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
||||||
}
|
}
|
||||||
@@ -520,7 +470,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Done")
|
fmt.Println("Done")
|
||||||
fmt.Println("✓ Downloaded successfully from Amazon Music")
|
fmt.Println("Downloaded successfully from Amazon Music")
|
||||||
return filePath, nil
|
return filePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
+129
-8
@@ -2,11 +2,138 @@ package backend
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const legacyTidalAPICacheFile = "tidal-api-urls.json"
|
||||||
|
|
||||||
|
func normalizeCustomTidalAPIValue(value interface{}) string {
|
||||||
|
customAPI, _ := value.(string)
|
||||||
|
customAPI = strings.TrimRight(strings.TrimSpace(customAPI), "/")
|
||||||
|
if strings.HasPrefix(customAPI, "https://") {
|
||||||
|
return customAPI
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeDownloaderValue(value interface{}, allowTidal bool) string {
|
||||||
|
downloader, _ := value.(string)
|
||||||
|
switch strings.TrimSpace(strings.ToLower(downloader)) {
|
||||||
|
case "tidal":
|
||||||
|
if allowTidal {
|
||||||
|
return "tidal"
|
||||||
|
}
|
||||||
|
return "auto"
|
||||||
|
case "qobuz":
|
||||||
|
return "qobuz"
|
||||||
|
case "amazon":
|
||||||
|
return "amazon"
|
||||||
|
default:
|
||||||
|
return "auto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeAutoOrderValue(value interface{}, allowTidal bool) string {
|
||||||
|
autoOrder, _ := value.(string)
|
||||||
|
allowed := map[string]struct{}{
|
||||||
|
"qobuz": {},
|
||||||
|
"amazon": {},
|
||||||
|
}
|
||||||
|
fallback := "qobuz-amazon"
|
||||||
|
if allowTidal {
|
||||||
|
allowed["tidal"] = struct{}{}
|
||||||
|
fallback = "tidal-qobuz-amazon"
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
parts := make([]string, 0, 3)
|
||||||
|
for _, rawPart := range strings.Split(strings.TrimSpace(strings.ToLower(autoOrder)), "-") {
|
||||||
|
part := strings.TrimSpace(rawPart)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := allowed[part]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[part]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[part] = struct{}{}
|
||||||
|
parts = append(parts, part)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(parts, "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
func SanitizeSettingsMap(settings map[string]interface{}) map[string]interface{} {
|
||||||
|
if settings == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitized := make(map[string]interface{}, len(settings))
|
||||||
|
for key, value := range settings {
|
||||||
|
sanitized[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
customAPI := normalizeCustomTidalAPIValue(sanitized["customTidalApi"])
|
||||||
|
sanitized["customTidalApi"] = customAPI
|
||||||
|
allowTidal := customAPI != ""
|
||||||
|
sanitized["downloader"] = sanitizeDownloaderValue(sanitized["downloader"], allowTidal)
|
||||||
|
sanitized["autoOrder"] = sanitizeAutoOrderValue(sanitized["autoOrder"], allowTidal)
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
func CleanupLegacyTidalPublicAPIState() error {
|
||||||
|
appDir, err := EnsureAppDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cachePath := filepath.Join(appDir, legacyTidalAPICacheFile)
|
||||||
|
if err := os.Remove(cachePath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SanitizePersistedConfigSettings() error {
|
||||||
|
configPath, err := GetConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &settings); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitized := SanitizeSettingsMap(settings)
|
||||||
|
payload, err := json.MarshalIndent(sanitized, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(configPath, payload, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
func GetDefaultMusicPath() string {
|
func GetDefaultMusicPath() string {
|
||||||
|
|
||||||
homeDir, err := os.UserHomeDir()
|
homeDir, err := os.UserHomeDir()
|
||||||
@@ -47,7 +174,7 @@ func LoadConfigSettings() (map[string]interface{}, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return settings, nil
|
return SanitizeSettingsMap(settings), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetRedownloadWithSuffixSetting() bool {
|
func GetRedownloadWithSuffixSetting() bool {
|
||||||
@@ -66,13 +193,7 @@ func GetCustomTidalAPISetting() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
customAPI, _ := settings["customTidalApi"].(string)
|
return normalizeCustomTidalAPIValue(settings["customTidalApi"])
|
||||||
customAPI = strings.TrimRight(strings.TrimSpace(customAPI), "/")
|
|
||||||
if strings.HasPrefix(customAPI, "https://") {
|
|
||||||
return customAPI
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeExistingFileCheckMode(value string) string {
|
func normalizeExistingFileCheckMode(value string) string {
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
+39
-1
@@ -11,6 +11,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -373,7 +374,7 @@ func InstallFFmpegWithBrew(progressCallback func(int, string)) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const ffmpegReleaseBaseURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.1"
|
const ffmpegReleaseBaseURL = "https://github.com/spotbye/Dependencies/releases/download/FFmpeg-8.1"
|
||||||
|
|
||||||
func buildFFmpegReleaseURL(assetName string) string {
|
func buildFFmpegReleaseURL(assetName string) string {
|
||||||
return ffmpegReleaseBaseURL + "/" + assetName
|
return ffmpegReleaseBaseURL + "/" + assetName
|
||||||
@@ -870,6 +871,36 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
|||||||
"-map", "0:a",
|
"-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)
|
args = append(args, outputFile)
|
||||||
@@ -924,6 +955,13 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
|||||||
return results, nil
|
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 {
|
type AudioFileInfo struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
|
|||||||
+9
-8
@@ -149,14 +149,15 @@ func ClearHistory(appName string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type FetchHistoryItem struct {
|
type FetchHistoryItem struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Info string `json:"info"`
|
Info string `json:"info"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
Data string `json:"data"`
|
Data string `json:"data"`
|
||||||
Timestamp int64 `json:"timestamp"`
|
IsExplicit bool `json:"is_explicit,omitempty"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
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: "*.*",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -41,6 +41,9 @@ var (
|
|||||||
currentSpeed float64
|
currentSpeed float64
|
||||||
speedLock sync.RWMutex
|
speedLock sync.RWMutex
|
||||||
|
|
||||||
|
rateLimitUntilMs int64
|
||||||
|
rateLimitLock sync.RWMutex
|
||||||
|
|
||||||
downloadQueue []DownloadItem
|
downloadQueue []DownloadItem
|
||||||
downloadQueueLock sync.RWMutex
|
downloadQueueLock sync.RWMutex
|
||||||
currentItemID string
|
currentItemID string
|
||||||
@@ -55,6 +58,8 @@ type ProgressInfo struct {
|
|||||||
IsDownloading bool `json:"is_downloading"`
|
IsDownloading bool `json:"is_downloading"`
|
||||||
MBDownloaded float64 `json:"mb_downloaded"`
|
MBDownloaded float64 `json:"mb_downloaded"`
|
||||||
SpeedMBps float64 `json:"speed_mbps"`
|
SpeedMBps float64 `json:"speed_mbps"`
|
||||||
|
RateLimited bool `json:"rate_limited"`
|
||||||
|
RateLimitSecs int `json:"rate_limit_secs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownloadQueueInfo struct {
|
type DownloadQueueInfo struct {
|
||||||
@@ -82,13 +87,45 @@ func GetDownloadProgress() ProgressInfo {
|
|||||||
speed := currentSpeed
|
speed := currentSpeed
|
||||||
speedLock.RUnlock()
|
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{
|
return ProgressInfo{
|
||||||
IsDownloading: downloading,
|
IsDownloading: downloading,
|
||||||
MBDownloaded: progress,
|
MBDownloaded: progress,
|
||||||
SpeedMBps: speed,
|
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) {
|
func SetDownloadSpeed(mbps float64) {
|
||||||
speedLock.Lock()
|
speedLock.Lock()
|
||||||
currentSpeed = mbps
|
currentSpeed = mbps
|
||||||
@@ -110,6 +147,7 @@ func SetDownloading(downloading bool) {
|
|||||||
|
|
||||||
SetDownloadProgress(0)
|
SetDownloadProgress(0)
|
||||||
SetDownloadSpeed(0)
|
SetDownloadSpeed(0)
|
||||||
|
ClearRateLimitCooldown()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +185,10 @@ func getCurrentTimeMillis() int64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (pw *ProgressWriter) Write(p []byte) (int, error) {
|
func (pw *ProgressWriter) Write(p []byte) (int, error) {
|
||||||
|
if err := CheckDownloadCancelled(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
n, err := pw.writer.Write(p)
|
n, err := pw.writer.Write(p)
|
||||||
pw.total += int64(n)
|
pw.total += int64(n)
|
||||||
|
|
||||||
@@ -396,6 +438,25 @@ 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() {
|
func ResetSessionIfComplete() {
|
||||||
downloadQueueLock.RLock()
|
downloadQueueLock.RLock()
|
||||||
hasActiveOrQueued := false
|
hasActiveOrQueued := false
|
||||||
|
|||||||
@@ -1,21 +1,88 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
const amazonMusicAPIBaseURL = "https://amazon.spotbye.qzz.io"
|
import (
|
||||||
const qobuzMusicDLDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download"
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
var defaultQobuzStreamAPIBaseURLs = []string{
|
const amazonMusicAPIBaseURL = "https://amazon.spotbye.qzz.io"
|
||||||
"https://dab.yeet.su/api/stream?trackId=",
|
|
||||||
"https://dabmusic.xyz/api/stream?trackId=",
|
const (
|
||||||
|
qobuzWJHEBaseURL = "https://music.wjhe.top"
|
||||||
|
qobuzWJHESearchAPIURL = qobuzWJHEBaseURL + "/api/music/qobuz/search"
|
||||||
|
qobuzWJHEStreamAPIURL = qobuzWJHEBaseURL + "/api/music/qobuz/url"
|
||||||
|
qobuzMusicDLDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download"
|
||||||
|
qobuzGDStudioAPIURLXYZ = "https://music.gdstudio.xyz/api.php"
|
||||||
|
qobuzGDStudioAPIURLORG = "https://music.gdstudio.org/api.php"
|
||||||
|
qobuzGDStudioVersion = "2026.5.10"
|
||||||
|
)
|
||||||
|
|
||||||
|
var defaultQobuzDownloadProviderURLs = []string{
|
||||||
|
qobuzWJHEStreamAPIURL,
|
||||||
|
qobuzGDStudioAPIURLXYZ,
|
||||||
|
qobuzGDStudioAPIURLORG,
|
||||||
|
qobuzMusicDLDownloadAPIURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetQobuzStreamAPIBaseURLs() []string {
|
func GetQobuzDownloadProviderURLs() []string {
|
||||||
return append([]string(nil), defaultQobuzStreamAPIBaseURLs...)
|
return append([]string(nil), defaultQobuzDownloadProviderURLs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetQobuzWJHESearchAPIURL() string {
|
||||||
|
return qobuzWJHESearchAPIURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetQobuzWJHEStreamAPIURL() string {
|
||||||
|
return qobuzWJHEStreamAPIURL
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetQobuzMusicDLDownloadAPIURL() string {
|
func GetQobuzMusicDLDownloadAPIURL() string {
|
||||||
return qobuzMusicDLDownloadAPIURL
|
return qobuzMusicDLDownloadAPIURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetQobuzGDStudioAPIURLs() []string {
|
||||||
|
return []string{qobuzGDStudioAPIURLXYZ, qobuzGDStudioAPIURLORG}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetQobuzGDStudioPrimaryAPIURL() string {
|
||||||
|
return qobuzGDStudioAPIURLXYZ
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetQobuzGDStudioFallbackAPIURL() string {
|
||||||
|
return qobuzGDStudioAPIURLORG
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetQobuzGDStudioSignatureHost(apiURL string) string {
|
||||||
|
parsed, err := url.Parse(strings.TrimSpace(apiURL))
|
||||||
|
if err != nil || strings.TrimSpace(parsed.Host) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(parsed.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetQobuzGDStudioVersion() string {
|
||||||
|
return qobuzGDStudioVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsQobuzWJHEProviderURL(raw string) bool {
|
||||||
|
candidate := strings.TrimSpace(raw)
|
||||||
|
return candidate == qobuzWJHEStreamAPIURL || strings.HasPrefix(candidate, qobuzWJHEStreamAPIURL+"?")
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsQobuzMusicDLProviderURL(raw string) bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(raw), qobuzMusicDLDownloadAPIURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsQobuzGDStudioProviderURL(raw string) bool {
|
||||||
|
candidate := strings.TrimSpace(raw)
|
||||||
|
for _, apiURL := range GetQobuzGDStudioAPIURLs() {
|
||||||
|
if candidate == apiURL || strings.HasPrefix(candidate, apiURL+"?") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func GetAmazonMusicAPIBaseURL() string {
|
func GetAmazonMusicAPIBaseURL() string {
|
||||||
return amazonMusicAPIBaseURL
|
return amazonMusicAPIBaseURL
|
||||||
}
|
}
|
||||||
|
|||||||
+519
-124
@@ -4,7 +4,9 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"crypto/aes"
|
"crypto/aes"
|
||||||
"crypto/cipher"
|
"crypto/cipher"
|
||||||
|
"crypto/md5"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -13,24 +15,19 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type QobuzDownloader struct {
|
type QobuzDownloader struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
appID string
|
customURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
type QobuzSearchResponse struct {
|
func (q *QobuzDownloader) SetCustomAPIURL(apiURL string) {
|
||||||
Query string `json:"query"`
|
q.customURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||||
Tracks struct {
|
|
||||||
Limit int `json:"limit"`
|
|
||||||
Offset int `json:"offset"`
|
|
||||||
Total int `json:"total"`
|
|
||||||
Items []QobuzTrack `json:"items"`
|
|
||||||
} `json:"tracks"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type QobuzTrack struct {
|
type QobuzTrack struct {
|
||||||
@@ -69,10 +66,6 @@ type QobuzTrack struct {
|
|||||||
} `json:"album"`
|
} `json:"album"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type QobuzStreamResponse struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type qobuzMusicDLRequest struct {
|
type qobuzMusicDLRequest struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Quality string `json:"quality"`
|
Quality string `json:"quality"`
|
||||||
@@ -89,12 +82,20 @@ type qobuzMusicDLResponse struct {
|
|||||||
Error string `json:"error"`
|
Error string `json:"error"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const qobuzMusicDLProbeTrackID int64 = 341032040
|
type qobuzPublicSearchResponse struct {
|
||||||
|
Tracks struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Items []QobuzTrack `json:"items"`
|
||||||
|
} `json:"tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const qobuzProbeTrackID int64 = 341032040
|
||||||
|
|
||||||
var (
|
var (
|
||||||
qobuzMusicDLDebugKeyOnce sync.Once
|
qobuzMusicDLDebugKeyOnce sync.Once
|
||||||
qobuzMusicDLDebugKey string
|
qobuzMusicDLDebugKey string
|
||||||
qobuzMusicDLDebugKeyErr error
|
qobuzMusicDLDebugKeyErr error
|
||||||
|
qobuzStreamingURLPattern = regexp.MustCompile(`https?://[^\s"'<>\\)]+`)
|
||||||
)
|
)
|
||||||
|
|
||||||
var qobuzMusicDLDebugKeySeedParts = [][]byte{
|
var qobuzMusicDLDebugKeySeedParts = [][]byte{
|
||||||
@@ -129,7 +130,6 @@ func NewQobuzDownloader() *QobuzDownloader {
|
|||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Timeout: 60 * time.Second,
|
Timeout: 60 * time.Second,
|
||||||
},
|
},
|
||||||
appID: qobuzDefaultAPIAppID,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,112 +184,464 @@ func getQobuzMusicDLDebugKey() (string, error) {
|
|||||||
return qobuzMusicDLDebugKey, nil
|
return qobuzMusicDLDebugKey, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
|
func firstNonEmptyQobuzValue(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed != "" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeQobuzSearchValue(value string) string {
|
||||||
|
replacer := strings.NewReplacer(
|
||||||
|
"&", " and ",
|
||||||
|
"feat.", " ",
|
||||||
|
"ft.", " ",
|
||||||
|
"/", " ",
|
||||||
|
"-", " ",
|
||||||
|
"_", " ",
|
||||||
|
)
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||||
|
normalized = replacer.Replace(normalized)
|
||||||
|
return strings.Join(strings.Fields(normalized), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func qobuzTrackDisplayArtist(track QobuzTrack) string {
|
||||||
|
return firstNonEmptyQobuzValue(track.Performer.Name, track.Album.Artist.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func qobuzTrackSupportsHiRes(track QobuzTrack) bool {
|
||||||
|
if track.Hires || track.HiresStreamable {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return track.MaximumBitDepth >= 24 || track.MaximumSamplingRate > 48
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreQobuzSearchCandidate(track QobuzTrack, spotifyTrackName string, spotifyArtistName string, spotifyAlbumName string) int {
|
||||||
|
score := 0
|
||||||
|
|
||||||
|
titleNeedle := normalizeQobuzSearchValue(spotifyTrackName)
|
||||||
|
titleHaystack := normalizeQobuzSearchValue(track.Title)
|
||||||
|
switch {
|
||||||
|
case titleNeedle != "" && titleHaystack == titleNeedle:
|
||||||
|
score += 1000
|
||||||
|
case titleNeedle != "" && (strings.Contains(titleHaystack, titleNeedle) || strings.Contains(titleNeedle, titleHaystack)):
|
||||||
|
score += 500
|
||||||
|
}
|
||||||
|
|
||||||
|
artistNeedle := normalizeQobuzSearchValue(spotifyArtistName)
|
||||||
|
artistHaystack := normalizeQobuzSearchValue(qobuzTrackDisplayArtist(track))
|
||||||
|
switch {
|
||||||
|
case artistNeedle != "" && artistHaystack == artistNeedle:
|
||||||
|
score += 300
|
||||||
|
case artistNeedle != "" && artistHaystack != "" && (strings.Contains(artistHaystack, artistNeedle) || strings.Contains(artistNeedle, artistHaystack)):
|
||||||
|
score += 180
|
||||||
|
}
|
||||||
|
|
||||||
|
albumNeedle := normalizeQobuzSearchValue(spotifyAlbumName)
|
||||||
|
albumHaystack := normalizeQobuzSearchValue(track.Album.Title)
|
||||||
|
switch {
|
||||||
|
case albumNeedle != "" && albumHaystack == albumNeedle:
|
||||||
|
score += 150
|
||||||
|
case albumNeedle != "" && albumHaystack != "" && (strings.Contains(albumHaystack, albumNeedle) || strings.Contains(albumNeedle, albumHaystack)):
|
||||||
|
score += 90
|
||||||
|
}
|
||||||
|
|
||||||
|
if qobuzTrackSupportsHiRes(track) {
|
||||||
|
score += 40
|
||||||
|
} else if track.MaximumBitDepth >= 16 {
|
||||||
|
score += 20
|
||||||
|
}
|
||||||
|
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapQobuzWJHEQuality(quality string) (int, string) {
|
||||||
|
switch strings.TrimSpace(quality) {
|
||||||
|
case "27", "7":
|
||||||
|
return 2000, "flac"
|
||||||
|
case "", "6":
|
||||||
|
return 1000, "flac"
|
||||||
|
default:
|
||||||
|
return 320, "mp3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildQobuzWJHEDownloadURL(trackID int64, quality string) string {
|
||||||
|
wjheQuality, wjheFormat := mapQobuzWJHEQuality(quality)
|
||||||
|
params := url.Values{
|
||||||
|
"ID": {strconv.FormatInt(trackID, 10)},
|
||||||
|
"quality": {strconv.Itoa(wjheQuality)},
|
||||||
|
"format": {wjheFormat},
|
||||||
|
}
|
||||||
|
return GetQobuzWJHEStreamAPIURL() + "?" + params.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func qobuzURLLooksStreamable(raw string) bool {
|
||||||
|
candidate := strings.TrimSpace(raw)
|
||||||
|
if candidate == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(candidate)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func findQobuzStreamingURLInPayload(payload interface{}) string {
|
||||||
|
switch value := payload.(type) {
|
||||||
|
case string:
|
||||||
|
candidate := strings.ReplaceAll(strings.TrimSpace(value), `\/`, `/`)
|
||||||
|
if qobuzURLLooksStreamable(candidate) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
case []interface{}:
|
||||||
|
for _, item := range value {
|
||||||
|
if url := findQobuzStreamingURLInPayload(item); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case map[string]interface{}:
|
||||||
|
for _, key := range []string{"download_url", "url", "play_url", "stream_url", "link", "file"} {
|
||||||
|
if nested, ok := value[key]; ok {
|
||||||
|
if url := findQobuzStreamingURLInPayload(nested); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, nested := range value {
|
||||||
|
if url := findQobuzStreamingURLInPayload(nested); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractQobuzStreamingURL(body []byte) string {
|
||||||
|
trimmed := strings.TrimSpace(string(body))
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var directResp struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
DownloadURL string `json:"download_url"`
|
||||||
|
Data struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
DownloadURL string `json:"download_url"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &directResp); err == nil {
|
||||||
|
for _, candidate := range []string{
|
||||||
|
directResp.DownloadURL,
|
||||||
|
directResp.URL,
|
||||||
|
directResp.Data.DownloadURL,
|
||||||
|
directResp.Data.URL,
|
||||||
|
} {
|
||||||
|
if qobuzURLLooksStreamable(candidate) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var genericPayload interface{}
|
||||||
|
if err := json.Unmarshal(body, &genericPayload); err == nil {
|
||||||
|
if streamURL := findQobuzStreamingURLInPayload(genericPayload); streamURL != "" {
|
||||||
|
return streamURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if openIdx := strings.Index(trimmed, "("); openIdx >= 0 {
|
||||||
|
if closeIdx := strings.LastIndex(trimmed, ")"); closeIdx > openIdx+1 {
|
||||||
|
callbackBody := strings.TrimSpace(trimmed[openIdx+1 : closeIdx])
|
||||||
|
if streamURL := extractQobuzStreamingURL([]byte(callbackBody)); streamURL != "" {
|
||||||
|
return streamURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, match := range qobuzStreamingURLPattern.FindAllString(trimmed, -1) {
|
||||||
|
candidate := strings.ReplaceAll(match, `\/`, `/`)
|
||||||
|
if qobuzURLLooksStreamable(candidate) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func newQobuzNoRedirectClient(base *http.Client) *http.Client {
|
||||||
|
if base == nil {
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: 20 * time.Second,
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cloned := *base
|
||||||
|
if cloned.Timeout == 0 {
|
||||||
|
cloned.Timeout = 20 * time.Second
|
||||||
|
}
|
||||||
|
cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
}
|
||||||
|
return &cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QobuzDownloader) searchByISRC(isrc string, spotifyTrackName string, spotifyArtistName string, spotifyAlbumName string) (*QobuzTrack, error) {
|
||||||
if strings.HasPrefix(isrc, "qobuz_") {
|
if strings.HasPrefix(isrc, "qobuz_") {
|
||||||
trackID := strings.TrimPrefix(isrc, "qobuz_")
|
trackID := strings.TrimSpace(strings.TrimPrefix(isrc, "qobuz_"))
|
||||||
resp, err := doQobuzSignedRequest(http.MethodGet, "track/get", url.Values{"track_id": {trackID}}, q.client)
|
resp, err := doQobuzSignedRequest(http.MethodGet, "track/get", url.Values{"track_id": {trackID}}, q.client)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch track: %w", err)
|
return nil, fmt.Errorf("failed to fetch track from Qobuz public API: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
return nil, fmt.Errorf("Qobuz public API track/get returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
|
||||||
}
|
}
|
||||||
|
|
||||||
var trackResp QobuzTrack
|
var trackResp QobuzTrack
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&trackResp); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&trackResp); err != nil {
|
||||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
return nil, fmt.Errorf("failed to decode Qobuz public track/get response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &trackResp, nil
|
return &trackResp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := doQobuzSignedRequest(http.MethodGet, "track/search", url.Values{
|
queries := []string{strings.TrimSpace(isrc)}
|
||||||
"query": {isrc},
|
if fallbackQuery := strings.TrimSpace(strings.Join([]string{spotifyTrackName, spotifyArtistName}, " ")); fallbackQuery != "" {
|
||||||
"limit": {"1"},
|
queries = append(queries, fallbackQuery)
|
||||||
}, q.client)
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for _, query := range queries {
|
||||||
|
if strings.TrimSpace(query) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchResp qobuzPublicSearchResponse
|
||||||
|
if err := doQobuzSignedJSONRequest("track/search", url.Values{
|
||||||
|
"query": {strings.TrimSpace(query)},
|
||||||
|
"limit": {"10"},
|
||||||
|
}, &searchResp); err != nil {
|
||||||
|
lastErr = fmt.Errorf("failed to search Qobuz public API: %w", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if searchResp.Tracks.Total == 0 || len(searchResp.Tracks.Items) == 0 {
|
||||||
|
lastErr = fmt.Errorf("track not found for query: %s", query)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
bestIndex := 0
|
||||||
|
bestScore := -1
|
||||||
|
for idx, candidate := range searchResp.Tracks.Items {
|
||||||
|
score := scoreQobuzSearchCandidate(candidate, spotifyTrackName, spotifyArtistName, spotifyAlbumName)
|
||||||
|
if idx == 0 || score > bestScore {
|
||||||
|
bestIndex = idx
|
||||||
|
bestScore = score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := searchResp.Tracks.Items[bestIndex]
|
||||||
|
return &selected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = fmt.Errorf("track not found for ISRC: %s", isrc)
|
||||||
|
}
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QobuzDownloader) DownloadFromWJHE(trackID int64, quality string) (string, error) {
|
||||||
|
apiURL := buildQobuzWJHEDownloadURL(trackID, quality)
|
||||||
|
client := newQobuzNoRedirectClient(q.client)
|
||||||
|
|
||||||
|
req, err := NewRequestWithDefaultHeaders(http.MethodHead, apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to search track: %w", err)
|
return "", fmt.Errorf("failed to create WJHE request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to reach WJHE: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotImplemented {
|
||||||
|
resp.Body.Close()
|
||||||
|
req, err = NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create WJHE fallback request: %w", err)
|
||||||
|
}
|
||||||
|
resp, err = client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to reach WJHE with GET fallback: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if location := strings.TrimSpace(resp.Header.Get("Location")); qobuzURLLooksStreamable(location) {
|
||||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
return location, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var searchResp QobuzSearchResponse
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 128*1024))
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
return "", fmt.Errorf("failed to read WJHE response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(body) == 0 {
|
if streamURL := extractQobuzStreamingURL(body); streamURL != "" {
|
||||||
return nil, fmt.Errorf("API returned empty response")
|
return streamURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &searchResp); err != nil {
|
if resp.Request != nil && resp.Request.URL != nil {
|
||||||
|
if streamURL := strings.TrimSpace(resp.Request.URL.String()); streamURL != "" && streamURL != apiURL && qobuzURLLooksStreamable(streamURL) {
|
||||||
bodyStr := string(body)
|
return streamURL, nil
|
||||||
if len(bodyStr) > 200 {
|
|
||||||
bodyStr = bodyStr[:200] + "..."
|
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(searchResp.Tracks.Items) == 0 {
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
|
||||||
return nil, fmt.Errorf("track not found for ISRC: %s", isrc)
|
return "", fmt.Errorf("WJHE returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
|
||||||
}
|
}
|
||||||
|
|
||||||
return &searchResp.Tracks.Items[0], nil
|
return "", fmt.Errorf("WJHE response did not include a stream URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildQobuzAPIURL(apiBase string, trackID int64, quality string) string {
|
func qobuzGDStudioPaddedVersion() string {
|
||||||
return fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
|
parts := strings.Split(GetQobuzGDStudioVersion(), ".")
|
||||||
|
for idx, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if len(part) == 1 {
|
||||||
|
part = "0" + part
|
||||||
|
}
|
||||||
|
parts[idx] = part
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, quality string) (string, error) {
|
func qobuzGDStudioEscapedValue(value string) string {
|
||||||
apiURL := buildQobuzAPIURL(apiBase, trackID, quality)
|
return strings.ReplaceAll(url.QueryEscape(strings.TrimSpace(value)), "+", "%20")
|
||||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
|
}
|
||||||
|
|
||||||
|
func (q *QobuzDownloader) getQobuzGDStudioTS9(apiURL string) string {
|
||||||
|
fallback := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||||
|
if len(fallback) >= 9 {
|
||||||
|
fallback = fallback[:9]
|
||||||
|
}
|
||||||
|
|
||||||
|
client := q.client
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 10 * time.Second}
|
||||||
|
}
|
||||||
|
|
||||||
|
signatureHost := GetQobuzGDStudioSignatureHost(apiURL)
|
||||||
|
if signatureHost == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := NewRequestWithDefaultHeaders(http.MethodGet, fmt.Sprintf("https://%s/time", signatureHost), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 64))
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp := strings.TrimSpace(string(body))
|
||||||
|
if len(timestamp) >= 9 {
|
||||||
|
return timestamp[:9]
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildQobuzGDStudioSignature(apiURL string, value string, ts9 string) string {
|
||||||
|
signatureHost := GetQobuzGDStudioSignatureHost(apiURL)
|
||||||
|
signatureBase := fmt.Sprintf("%s|%s|%s|%s", signatureHost, qobuzGDStudioPaddedVersion(), ts9, qobuzGDStudioEscapedValue(value))
|
||||||
|
sum := md5.Sum([]byte(signatureBase))
|
||||||
|
digest := hex.EncodeToString(sum[:])
|
||||||
|
return strings.ToUpper(digest[len(digest)-8:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapQobuzGDStudioBitrate(quality string) string {
|
||||||
|
switch strings.TrimSpace(quality) {
|
||||||
|
case "27", "7":
|
||||||
|
return "999"
|
||||||
|
case "", "6":
|
||||||
|
return "740"
|
||||||
|
default:
|
||||||
|
return "320"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QobuzDownloader) DownloadFromGDStudio(trackID int64, quality string, apiURL string) (string, error) {
|
||||||
|
apiURL = strings.TrimSpace(apiURL)
|
||||||
|
if apiURL == "" {
|
||||||
|
apiURL = GetQobuzGDStudioPrimaryAPIURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
signatureHost := GetQobuzGDStudioSignatureHost(apiURL)
|
||||||
|
if signatureHost == "" {
|
||||||
|
return "", fmt.Errorf("GDStudio API URL is invalid: %s", apiURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
trackIDString := strconv.FormatInt(trackID, 10)
|
||||||
|
ts9 := q.getQobuzGDStudioTS9(apiURL)
|
||||||
|
payload := url.Values{
|
||||||
|
"types": {"url"},
|
||||||
|
"id": {trackIDString},
|
||||||
|
"source": {"qobuz"},
|
||||||
|
"br": {mapQobuzGDStudioBitrate(quality)},
|
||||||
|
"s": {buildQobuzGDStudioSignature(apiURL, trackIDString, ts9)},
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := NewRequestWithDefaultHeaders(http.MethodPost, apiURL, strings.NewReader(payload.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create GDStudio request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||||
|
req.Header.Set("Origin", fmt.Sprintf("https://%s", signatureHost))
|
||||||
|
req.Header.Set("Referer", fmt.Sprintf("https://%s/", signatureHost))
|
||||||
|
|
||||||
resp, err := q.client.Do(req)
|
resp, err := q.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", fmt.Errorf("failed to reach GDStudio: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
|
||||||
return "", fmt.Errorf("status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", fmt.Errorf("failed to read GDStudio response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(body) == 0 {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", fmt.Errorf("empty body")
|
return "", fmt.Errorf("GDStudio returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
|
||||||
}
|
}
|
||||||
|
|
||||||
var streamResp QobuzStreamResponse
|
streamURL := extractQobuzStreamingURL(body)
|
||||||
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
|
if streamURL == "" {
|
||||||
return streamResp.URL, nil
|
return "", fmt.Errorf("GDStudio response did not include a stream URL: %s", previewQobuzResponseBody(body, 256))
|
||||||
}
|
}
|
||||||
|
|
||||||
var nestedResp struct {
|
return streamURL, nil
|
||||||
Data struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &nestedResp); err == nil && nestedResp.Data.URL != "" {
|
|
||||||
return nestedResp.Data.URL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("invalid response")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *QobuzDownloader) DownloadFromMusicDL(trackID int64, quality string) (string, error) {
|
func (q *QobuzDownloader) DownloadFromMusicDL(trackID int64, quality string) (string, error) {
|
||||||
@@ -357,14 +709,46 @@ func (q *QobuzDownloader) DownloadFromMusicDL(trackID int64, quality string) (st
|
|||||||
return downloadURL, nil
|
return downloadURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func CheckQobuzMusicDLStatus(client *http.Client) bool {
|
func CheckQobuzMusicDLStatusDetailed(client *http.Client) error {
|
||||||
if client == nil {
|
if client == nil {
|
||||||
client = &http.Client{Timeout: 4 * time.Second}
|
client = &http.Client{Timeout: 4 * time.Second}
|
||||||
}
|
}
|
||||||
|
|
||||||
downloader := &QobuzDownloader{client: client, appID: qobuzDefaultAPIAppID}
|
downloader := &QobuzDownloader{client: client}
|
||||||
_, err := downloader.DownloadFromMusicDL(qobuzMusicDLProbeTrackID, "27")
|
_, err := downloader.DownloadFromMusicDL(qobuzProbeTrackID, "27")
|
||||||
return err == nil
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckQobuzMusicDLStatus(client *http.Client) bool {
|
||||||
|
return CheckQobuzMusicDLStatusDetailed(client) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckQobuzWJHEStatusDetailed(client *http.Client) error {
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 4 * time.Second}
|
||||||
|
}
|
||||||
|
|
||||||
|
downloader := &QobuzDownloader{client: client}
|
||||||
|
_, err := downloader.DownloadFromWJHE(qobuzProbeTrackID, "27")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckQobuzWJHEStatus(client *http.Client) bool {
|
||||||
|
return CheckQobuzWJHEStatusDetailed(client) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckQobuzGDStudioAPIStatusDetailed(client *http.Client, apiURL string) error {
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 4 * time.Second}
|
||||||
|
}
|
||||||
|
|
||||||
|
downloader := &QobuzDownloader{client: client}
|
||||||
|
_, err := downloader.DownloadFromGDStudio(qobuzProbeTrackID, "27", apiURL)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckQobuzGDStudioAPIStatus(client *http.Client, apiURL string) bool {
|
||||||
|
return CheckQobuzGDStudioAPIStatusDetailed(client, apiURL) == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFallback bool) (string, error) {
|
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFallback bool) (string, error) {
|
||||||
@@ -375,66 +759,62 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
|
|||||||
|
|
||||||
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
|
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
downloadFunc := func(qual string) (string, error) {
|
downloadFunc := func(qual string) (string, error) {
|
||||||
type Provider struct {
|
if url, err := q.getQobuzCommunityDownloadURL(trackID, qual); err == nil {
|
||||||
Name string
|
fmt.Printf("Success (community qbz-a)\n")
|
||||||
API string
|
return url, nil
|
||||||
Func func() (string, error)
|
} else if IsDownloadCancelledError(err) {
|
||||||
|
return "", err
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Community qbz-a failed: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
providerMap := make(map[string]Provider)
|
attemptMap := make(map[string]qobuzProviderAttempt)
|
||||||
providerIDs := []string{GetQobuzMusicDLDownloadAPIURL()}
|
attemptIDs := make([]string, 0, len(GetQobuzDownloadProviderURLs()))
|
||||||
|
for _, provider := range q.getQobuzDownloadProviders() {
|
||||||
providerMap[GetQobuzMusicDLDownloadAPIURL()] = Provider{
|
for _, attempt := range provider.Attempts(trackID, qual) {
|
||||||
Name: "MusicDL",
|
attemptMap[attempt.ID] = attempt
|
||||||
API: GetQobuzMusicDLDownloadAPIURL(),
|
attemptIDs = append(attemptIDs, attempt.ID)
|
||||||
Func: func() (string, error) {
|
|
||||||
return q.DownloadFromMusicDL(trackID, qual)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, api := range GetQobuzStreamAPIBaseURLs() {
|
|
||||||
currentAPI := api
|
|
||||||
providerIDs = append(providerIDs, currentAPI)
|
|
||||||
providerMap[currentAPI] = Provider{
|
|
||||||
Name: "Standard(" + currentAPI + ")",
|
|
||||||
API: currentAPI,
|
|
||||||
Func: func() (string, error) {
|
|
||||||
return q.DownloadFromStandard(currentAPI, trackID, qual)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
orderedProviderIDs := prioritizeProviders("qobuz", providerIDs)
|
orderedProviderIDs := prioritizeProviders("qobuz", attemptIDs)
|
||||||
primaryProviderID := GetQobuzMusicDLDownloadAPIURL()
|
orderedProviderIDs = moveQobuzAttemptIDsLast(orderedProviderIDs, GetQobuzMusicDLDownloadAPIURL())
|
||||||
if len(orderedProviderIDs) > 1 && orderedProviderIDs[0] != primaryProviderID {
|
|
||||||
reordered := []string{primaryProviderID}
|
|
||||||
for _, providerID := range orderedProviderIDs {
|
|
||||||
if providerID == primaryProviderID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
reordered = append(reordered, providerID)
|
|
||||||
}
|
|
||||||
orderedProviderIDs = reordered
|
|
||||||
}
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for _, providerID := range orderedProviderIDs {
|
for _, providerID := range orderedProviderIDs {
|
||||||
p, ok := providerMap[providerID]
|
attempt, ok := attemptMap[providerID]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Trying Provider: %s (Quality: %s)...\n", p.Name, qual)
|
fmt.Printf("Trying Provider: %s (Quality: %s)...\n", attempt.Name, qual)
|
||||||
|
|
||||||
url, err := p.Func()
|
url, err := attempt.Download()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
fmt.Printf("✓ Success\n")
|
fmt.Printf("Success\n")
|
||||||
recordProviderSuccess("qobuz", p.API)
|
recordProviderSuccess("qobuz", attempt.ID)
|
||||||
return url, nil
|
return url, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Provider failed: %v\n", err)
|
fmt.Printf("Provider failed: %v\n", err)
|
||||||
recordProviderFailure("qobuz", p.API)
|
recordProviderFailure("qobuz", attempt.ID)
|
||||||
lastErr = err
|
lastErr = err
|
||||||
}
|
}
|
||||||
return "", lastErr
|
return "", lastErr
|
||||||
@@ -444,27 +824,36 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return url, nil
|
return url, nil
|
||||||
}
|
}
|
||||||
|
if IsDownloadCancelledError(err) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
currentQuality := qualityCode
|
currentQuality := qualityCode
|
||||||
|
|
||||||
if currentQuality == "27" && allowFallback {
|
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")
|
url, err := downloadFunc("7")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
fmt.Println("✓ Success with fallback quality 7")
|
fmt.Println("Success with fallback quality 7")
|
||||||
return url, nil
|
return url, nil
|
||||||
}
|
}
|
||||||
|
if IsDownloadCancelledError(err) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
currentQuality = "7"
|
currentQuality = "7"
|
||||||
}
|
}
|
||||||
|
|
||||||
if currentQuality == "7" && allowFallback {
|
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")
|
url, err := downloadFunc("6")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
fmt.Println("✓ Success with fallback quality 6")
|
fmt.Println("Success with fallback quality 6")
|
||||||
return url, nil
|
return url, nil
|
||||||
}
|
}
|
||||||
|
if IsDownloadCancelledError(err) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("all APIs and fallbacks failed. Last error: %v", err)
|
return "", fmt.Errorf("all APIs and fallbacks failed. Last error: %v", err)
|
||||||
@@ -629,7 +1018,7 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena
|
|||||||
} else {
|
} else {
|
||||||
fmt.Println("Fetching MusicBrainz metadata...")
|
fmt.Println("Fetching MusicBrainz metadata...")
|
||||||
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
|
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
|
||||||
fmt.Println("✓ MusicBrainz metadata fetched")
|
fmt.Println("MusicBrainz metadata fetched")
|
||||||
metaChan <- fetchedMeta
|
metaChan <- fetchedMeta
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
||||||
@@ -647,7 +1036,7 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
track, err := q.searchByISRC(isrc)
|
track, err := q.searchByISRC(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -661,7 +1050,13 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena
|
|||||||
|
|
||||||
qualityInfo := "Standard"
|
qualityInfo := "Standard"
|
||||||
if track.Hires {
|
if track.Hires {
|
||||||
qualityInfo = fmt.Sprintf("Hi-Res (%d-bit / %.1f kHz)", track.MaximumBitDepth, track.MaximumSamplingRate)
|
if track.MaximumBitDepth > 0 && track.MaximumSamplingRate > 0 {
|
||||||
|
qualityInfo = fmt.Sprintf("Hi-Res (%d-bit / %.1f kHz)", track.MaximumBitDepth, track.MaximumSamplingRate)
|
||||||
|
} else if track.MaximumBitDepth > 0 {
|
||||||
|
qualityInfo = fmt.Sprintf("Hi-Res available (%d-bit)", track.MaximumBitDepth)
|
||||||
|
} else {
|
||||||
|
qualityInfo = "Hi-Res available"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fmt.Printf("Quality: %s\n", qualityInfo)
|
fmt.Printf("Quality: %s\n", qualityInfo)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
type qobuzDownloadProvider interface {
|
||||||
|
Name() string
|
||||||
|
Attempts(trackID int64, quality string) []qobuzProviderAttempt
|
||||||
|
}
|
||||||
|
|
||||||
|
type qobuzProviderAttempt struct {
|
||||||
|
Name string
|
||||||
|
ID string
|
||||||
|
Download func() (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type QobuzProviderWJHE struct {
|
||||||
|
downloader *QobuzDownloader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p QobuzProviderWJHE) Name() string {
|
||||||
|
return "QobuzProviderWJHE"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p QobuzProviderWJHE) Attempts(trackID int64, quality string) []qobuzProviderAttempt {
|
||||||
|
return []qobuzProviderAttempt{
|
||||||
|
{
|
||||||
|
Name: p.Name(),
|
||||||
|
ID: GetQobuzWJHEStreamAPIURL(),
|
||||||
|
Download: func() (string, error) {
|
||||||
|
return p.downloader.DownloadFromWJHE(trackID, quality)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type QobuzProviderMusicDL struct {
|
||||||
|
downloader *QobuzDownloader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p QobuzProviderMusicDL) Name() string {
|
||||||
|
return "QobuzProviderMusicDL"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p QobuzProviderMusicDL) Attempts(trackID int64, quality string) []qobuzProviderAttempt {
|
||||||
|
return []qobuzProviderAttempt{
|
||||||
|
{
|
||||||
|
Name: p.Name(),
|
||||||
|
ID: GetQobuzMusicDLDownloadAPIURL(),
|
||||||
|
Download: func() (string, error) {
|
||||||
|
return p.downloader.DownloadFromMusicDL(trackID, quality)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type QobuzProviderGDStudio struct {
|
||||||
|
downloader *QobuzDownloader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p QobuzProviderGDStudio) Name() string {
|
||||||
|
return "QobuzProviderGDStudio"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p QobuzProviderGDStudio) Attempts(trackID int64, quality string) []qobuzProviderAttempt {
|
||||||
|
attempts := make([]qobuzProviderAttempt, 0, len(GetQobuzGDStudioAPIURLs()))
|
||||||
|
for _, apiURL := range GetQobuzGDStudioAPIURLs() {
|
||||||
|
currentAPIURL := apiURL
|
||||||
|
attempts = append(attempts, qobuzProviderAttempt{
|
||||||
|
Name: p.Name(),
|
||||||
|
ID: currentAPIURL,
|
||||||
|
Download: func() (string, error) {
|
||||||
|
return p.downloader.DownloadFromGDStudio(trackID, quality, currentAPIURL)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return attempts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QobuzDownloader) getQobuzDownloadProviders() []qobuzDownloadProvider {
|
||||||
|
return []qobuzDownloadProvider{
|
||||||
|
QobuzProviderWJHE{downloader: q},
|
||||||
|
QobuzProviderGDStudio{downloader: q},
|
||||||
|
QobuzProviderMusicDL{downloader: q},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveQobuzAttemptIDsLast(providerIDs []string, lastIDs ...string) []string {
|
||||||
|
if len(providerIDs) == 0 || len(lastIDs) == 0 {
|
||||||
|
return append([]string(nil), providerIDs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIDSet := make(map[string]struct{}, len(lastIDs))
|
||||||
|
for _, providerID := range lastIDs {
|
||||||
|
lastIDSet[providerID] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
ordered := make([]string, 0, len(providerIDs))
|
||||||
|
trailing := make([]string, 0, len(providerIDs))
|
||||||
|
for _, providerID := range providerIDs {
|
||||||
|
if _, ok := lastIDSet[providerID]; ok {
|
||||||
|
trailing = append(trailing, providerID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ordered = append(ordered, providerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return append(ordered, trailing...)
|
||||||
|
}
|
||||||
@@ -11,13 +11,14 @@ import (
|
|||||||
const recentFetchesFileName = "recent_fetches.json"
|
const recentFetchesFileName = "recent_fetches.json"
|
||||||
|
|
||||||
type RecentFetchItem struct {
|
type RecentFetchItem struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Artist string `json:"artist"`
|
Artist string `json:"artist"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
Timestamp int64 `json:"timestamp"`
|
IsExplicit bool `json:"is_explicit,omitempty"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
+3
-3
@@ -420,17 +420,17 @@ func mergeSongLinkResponse(links *resolvedTrackLinks, resp *songLinkAPIResponse)
|
|||||||
|
|
||||||
if link, ok := resp.LinksByPlatform["tidal"]; ok && link.URL != "" && links.TidalURL == "" {
|
if link, ok := resp.LinksByPlatform["tidal"]; ok && link.URL != "" && links.TidalURL == "" {
|
||||||
links.TidalURL = strings.TrimSpace(link.URL)
|
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 == "" {
|
if link, ok := resp.LinksByPlatform["amazonMusic"]; ok && link.URL != "" && links.AmazonURL == "" {
|
||||||
links.AmazonURL = normalizeAmazonMusicURL(link.URL)
|
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 == "" {
|
if link, ok := resp.LinksByPlatform["deezer"]; ok && link.URL != "" && links.DeezerURL == "" {
|
||||||
links.DeezerURL = normalizeDeezerTrackURL(link.URL)
|
links.DeezerURL = normalizeDeezerTrackURL(link.URL)
|
||||||
fmt.Println("✓ Deezer URL found")
|
fmt.Println("Deezer URL found")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,19 +110,19 @@ func assignSongstatsLink(rawLink string, links *resolvedTrackLinks) {
|
|||||||
case strings.Contains(link, "listen.tidal.com/track"):
|
case strings.Contains(link, "listen.tidal.com/track"):
|
||||||
if links.TidalURL == "" {
|
if links.TidalURL == "" {
|
||||||
links.TidalURL = link
|
links.TidalURL = link
|
||||||
fmt.Println("✓ Tidal URL found via Songstats")
|
fmt.Println("Tidal URL found via Songstats")
|
||||||
}
|
}
|
||||||
case strings.Contains(link, "music.amazon.com"):
|
case strings.Contains(link, "music.amazon.com"):
|
||||||
if links.AmazonURL == "" {
|
if links.AmazonURL == "" {
|
||||||
if normalized := normalizeAmazonMusicURL(link); normalized != "" {
|
if normalized := normalizeAmazonMusicURL(link); normalized != "" {
|
||||||
links.AmazonURL = normalized
|
links.AmazonURL = normalized
|
||||||
fmt.Println("✓ Amazon URL found via Songstats")
|
fmt.Println("Amazon URL found via Songstats")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case strings.Contains(link, "deezer.com"):
|
case strings.Contains(link, "deezer.com"):
|
||||||
if links.DeezerURL == "" {
|
if links.DeezerURL == "" {
|
||||||
links.DeezerURL = normalizeDeezerTrackURL(link)
|
links.DeezerURL = normalizeDeezerTrackURL(link)
|
||||||
fmt.Println("✓ Deezer URL found via Songstats")
|
fmt.Println("Deezer URL found via Songstats")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ type AlbumInfoMetadata struct {
|
|||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
Artists string `json:"artists"`
|
Artists string `json:"artists"`
|
||||||
Images string `json:"images"`
|
Images string `json:"images"`
|
||||||
|
IsExplicit bool `json:"is_explicit,omitempty"`
|
||||||
UPC string `json:"upc,omitempty"`
|
UPC string `json:"upc,omitempty"`
|
||||||
Batch string `json:"batch,omitempty"`
|
Batch string `json:"batch,omitempty"`
|
||||||
ArtistID string `json:"artist_id,omitempty"`
|
ArtistID string `json:"artist_id,omitempty"`
|
||||||
@@ -162,6 +163,7 @@ type DiscographyAlbumMetadata struct {
|
|||||||
Artists string `json:"artists"`
|
Artists string `json:"artists"`
|
||||||
Images string `json:"images"`
|
Images string `json:"images"`
|
||||||
ExternalURL string `json:"external_urls"`
|
ExternalURL string `json:"external_urls"`
|
||||||
|
IsExplicit bool `json:"is_explicit,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ArtistDiscographyPayload struct {
|
type ArtistDiscographyPayload struct {
|
||||||
@@ -1104,12 +1106,21 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse, callback
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
albumExplicit := false
|
||||||
|
for _, track := range raw.Tracks {
|
||||||
|
if track.IsExplicit {
|
||||||
|
albumExplicit = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
info := AlbumInfoMetadata{
|
info := AlbumInfoMetadata{
|
||||||
TotalTracks: raw.Count,
|
TotalTracks: raw.Count,
|
||||||
Name: raw.Name,
|
Name: raw.Name,
|
||||||
ReleaseDate: raw.ReleaseDate,
|
ReleaseDate: raw.ReleaseDate,
|
||||||
Artists: raw.Artists,
|
Artists: raw.Artists,
|
||||||
Images: raw.Cover,
|
Images: raw.Cover,
|
||||||
|
IsExplicit: albumExplicit,
|
||||||
UPC: raw.UPC,
|
UPC: raw.UPC,
|
||||||
ArtistID: artistID,
|
ArtistID: artistID,
|
||||||
ArtistURL: artistURL,
|
ArtistURL: artistURL,
|
||||||
@@ -1276,8 +1287,10 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
|||||||
allTracks := make([]AlbumTrackMetadata, 0)
|
allTracks := make([]AlbumTrackMetadata, 0)
|
||||||
|
|
||||||
type fetchResult struct {
|
type fetchResult struct {
|
||||||
tracks []AlbumTrackMetadata
|
albumID string
|
||||||
err error
|
tracks []AlbumTrackMetadata
|
||||||
|
isExplicit bool
|
||||||
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
resultsChan := make(chan fetchResult, len(raw.Discography.All))
|
resultsChan := make(chan fetchResult, len(raw.Discography.All))
|
||||||
@@ -1318,7 +1331,7 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
|||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
resultsChan <- fetchResult{err: ctx.Err()}
|
resultsChan <- fetchResult{albumID: albumID, err: ctx.Err()}
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
@@ -1326,14 +1339,18 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
|||||||
albumData, err := c.fetchAlbumWithClient(ctx, sharedClient, albumID, nil)
|
albumData, err := c.fetchAlbumWithClient(ctx, sharedClient, albumID, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error getting tracks for album %s: %v\n", albumName, err)
|
fmt.Printf("Error getting tracks for album %s: %v\n", albumName, err)
|
||||||
resultsChan <- fetchResult{tracks: []AlbumTrackMetadata{}}
|
resultsChan <- fetchResult{albumID: albumID, tracks: []AlbumTrackMetadata{}}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(albumData.Tracks))
|
tracks := make([]AlbumTrackMetadata, 0, len(albumData.Tracks))
|
||||||
|
albumExplicit := false
|
||||||
for idx, tr := range albumData.Tracks {
|
for idx, tr := range albumData.Tracks {
|
||||||
durationMS := parseDuration(tr.Duration)
|
durationMS := parseDuration(tr.Duration)
|
||||||
trackNumber := idx + 1
|
trackNumber := idx + 1
|
||||||
|
if tr.IsExplicit {
|
||||||
|
albumExplicit = true
|
||||||
|
}
|
||||||
|
|
||||||
var artistID, artistURL string
|
var artistID, artistURL string
|
||||||
if len(tr.ArtistIds) > 0 {
|
if len(tr.ArtistIds) > 0 {
|
||||||
@@ -1377,7 +1394,7 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
|||||||
if callback != nil {
|
if callback != nil {
|
||||||
callback(tracks)
|
callback(tracks)
|
||||||
}
|
}
|
||||||
resultsChan <- fetchResult{tracks: tracks}
|
resultsChan <- fetchResult{albumID: albumID, tracks: tracks, isExplicit: albumExplicit}
|
||||||
}(alb.ID, alb.Name)
|
}(alb.ID, alb.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1386,6 +1403,12 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
|||||||
if res.err != nil {
|
if res.err != nil {
|
||||||
return nil, res.err
|
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...)
|
allTracks = append(allTracks, res.tracks...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-65
@@ -50,26 +50,10 @@ type TidalBTSManifest struct {
|
|||||||
|
|
||||||
func getConfiguredTidalAPIAttemptList() ([]string, error) {
|
func getConfiguredTidalAPIAttemptList() ([]string, error) {
|
||||||
customAPI := GetCustomTidalAPISetting()
|
customAPI := GetCustomTidalAPISetting()
|
||||||
apis, err := GetRotatedTidalAPIList()
|
|
||||||
if customAPI == "" {
|
if customAPI == "" {
|
||||||
return apis, err
|
return nil, fmt.Errorf("no configured custom tidal api instance")
|
||||||
}
|
}
|
||||||
|
return []string{customAPI}, nil
|
||||||
if err != nil && len(apis) == 0 {
|
|
||||||
return []string{customAPI}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
result := make([]string, 0, len(apis)+1)
|
|
||||||
result = append(result, customAPI)
|
|
||||||
for _, apiURL := range apis {
|
|
||||||
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
|
||||||
if apiURL == "" || apiURL == customAPI {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result = append(result, apiURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildTidalOutputPath(outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyTrackNumber, spotifyDiscNumber int, isrcOverride string, useFirstArtistOnly bool) (string, bool, error) {
|
func buildTidalOutputPath(outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyTrackNumber, spotifyDiscNumber int, isrcOverride string, useFirstArtistOnly bool) (string, bool, error) {
|
||||||
@@ -129,7 +113,7 @@ func finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName,
|
|||||||
fmt.Println("Fetching MusicBrainz metadata...")
|
fmt.Println("Fetching MusicBrainz metadata...")
|
||||||
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil {
|
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil {
|
||||||
res.Metadata = fetchedMeta
|
res.Metadata = fetchedMeta
|
||||||
fmt.Println("✓ MusicBrainz metadata fetched")
|
fmt.Println("MusicBrainz metadata fetched")
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
||||||
}
|
}
|
||||||
@@ -212,13 +196,6 @@ func finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName,
|
|||||||
|
|
||||||
func NewTidalDownloader(apiURL string) *TidalDownloader {
|
func NewTidalDownloader(apiURL string) *TidalDownloader {
|
||||||
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||||
if apiURL == "" {
|
|
||||||
apis, err := getConfiguredTidalAPIAttemptList()
|
|
||||||
if err == nil && len(apis) > 0 {
|
|
||||||
apiURL = apis[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &TidalDownloader{
|
return &TidalDownloader{
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Timeout: 5 * time.Second,
|
Timeout: 5 * time.Second,
|
||||||
@@ -275,37 +252,41 @@ func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
|
|||||||
|
|
||||||
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
||||||
fmt.Println("Fetching URL...")
|
fmt.Println("Fetching URL...")
|
||||||
|
if strings.TrimSpace(t.apiURL) == "" {
|
||||||
|
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)
|
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality)
|
||||||
fmt.Printf("Tidal API URL: %s\n", url)
|
fmt.Printf("Tidal API URL: %s\n", url)
|
||||||
|
|
||||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
|
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
|
||||||
if err != 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)
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := t.client.Do(req)
|
resp, err := t.client.Do(req)
|
||||||
if err != nil {
|
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)
|
return "", fmt.Errorf("failed to get download URL: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
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)
|
return "", fmt.Errorf("API returned status code: %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
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)
|
return "", fmt.Errorf("failed to read response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var v2Response TidalAPIResponseV2
|
var v2Response TidalAPIResponseV2
|
||||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
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
|
return "MANIFEST:" + v2Response.Data.Manifest, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,23 +297,23 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||||||
if len(bodyStr) > 200 {
|
if len(bodyStr) > 200 {
|
||||||
bodyStr = 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)
|
return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(apiResponses) == 0 {
|
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")
|
return "", fmt.Errorf("no download URL in response")
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, item := range apiResponses {
|
for _, item := range apiResponses {
|
||||||
if item.OriginalTrackURL != "" {
|
if item.OriginalTrackURL != "" {
|
||||||
fmt.Println("✓ Tidal download URL found")
|
fmt.Println("Tidal download URL found")
|
||||||
return item.OriginalTrackURL, nil
|
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")
|
return "", fmt.Errorf("download URL not found in response")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,7 +328,8 @@ func (t *TidalDownloader) DownloadFile(url, filepath string, quality string) err
|
|||||||
return fmt.Errorf("failed to create request: %w", err)
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := t.client.Do(req)
|
downloadClient := &http.Client{Timeout: 5 * time.Minute}
|
||||||
|
resp, err := downloadClient.Do(req)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to download file: %w", err)
|
return fmt.Errorf("failed to download file: %w", err)
|
||||||
@@ -590,8 +572,11 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
|||||||
|
|
||||||
downloadURL, err := t.GetDownloadURL(trackID, quality)
|
downloadURL, err := t.GetDownloadURL(trackID, quality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if IsDownloadCancelledError(err) {
|
||||||
|
return outputFilename, err
|
||||||
|
}
|
||||||
if isTidalHiResQuality(quality) && allowFallback {
|
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")
|
downloadURL, err = t.GetDownloadURL(trackID, "LOSSLESS")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return outputFilename, fmt.Errorf("failed to get download URL (HI_RES & LOSSLESS both failed): %w", err)
|
return outputFilename, fmt.Errorf("failed to get download URL (HI_RES & LOSSLESS both failed): %w", err)
|
||||||
@@ -606,16 +591,11 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
|||||||
cleanupTidalDownloadArtifacts(outputFilename)
|
cleanupTidalDownloadArtifacts(outputFilename)
|
||||||
return outputFilename, err
|
return outputFilename, err
|
||||||
}
|
}
|
||||||
if t.apiURL != "" {
|
|
||||||
if err := RememberTidalAPIUsage(t.apiURL); err != nil {
|
|
||||||
fmt.Printf("Warning: failed to persist last used Tidal API: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre)
|
finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre)
|
||||||
|
|
||||||
fmt.Println("Done")
|
fmt.Println("Done")
|
||||||
fmt.Println("✓ Downloaded successfully from Tidal")
|
fmt.Println("Downloaded successfully from Tidal")
|
||||||
return outputFilename, nil
|
return outputFilename, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,12 +626,12 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
|||||||
cleanupTidalDownloadArtifacts(outputFilename)
|
cleanupTidalDownloadArtifacts(outputFilename)
|
||||||
return outputFilename, err
|
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)
|
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("Done")
|
||||||
fmt.Println("✓ Downloaded successfully from Tidal")
|
fmt.Println("Downloaded successfully from Tidal")
|
||||||
return outputFilename, nil
|
return outputFilename, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,11 +642,7 @@ func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameF
|
|||||||
return "", fmt.Errorf("songlink/songstats couldn't find Tidal URL: %w", err)
|
return "", fmt.Errorf("songlink/songstats couldn't find Tidal URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.apiURL != "" {
|
return t.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.DownloadByURL(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
|
|
||||||
}
|
|
||||||
|
|
||||||
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SegmentTemplate struct {
|
type SegmentTemplate struct {
|
||||||
@@ -846,7 +822,7 @@ func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename
|
|||||||
var lastErr error
|
var lastErr error
|
||||||
for idx, candidateQuality := range qualities {
|
for idx, candidateQuality := range qualities {
|
||||||
if idx > 0 {
|
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)
|
apiURL, err := t.tryDownloadAcrossTidalAPIs(trackID, outputFilename, candidateQuality, false)
|
||||||
@@ -892,29 +868,16 @@ func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilena
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := RememberTidalAPIUsage(apiURL); err != nil {
|
|
||||||
fmt.Printf("Warning: failed to persist last used Tidal API: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return apiURL, nil
|
return apiURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !refreshed {
|
|
||||||
if _, refreshErr := RefreshTidalAPIList(true); refreshErr != nil {
|
|
||||||
errors = append(errors, fmt.Sprintf("gist refresh failed: %v", refreshErr))
|
|
||||||
} else {
|
|
||||||
fmt.Println("All cached Tidal APIs failed, refreshed gist list and retrying...")
|
|
||||||
return t.tryDownloadAcrossTidalAPIs(trackID, outputFilename, quality, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if lastErr == nil {
|
if lastErr == nil {
|
||||||
lastErr = fmt.Errorf("all tidal apis failed")
|
lastErr = fmt.Errorf("all tidal apis failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("All Tidal APIs failed:")
|
fmt.Println("All Tidal APIs failed:")
|
||||||
for _, item := range errors {
|
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)
|
return "", fmt.Errorf("all tidal apis failed for quality %s: %w", quality, lastErr)
|
||||||
|
|||||||
@@ -1,296 +0,0 @@
|
|||||||
package backend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
tidalAPIListGistURL = "https://gist.githubusercontent.com/afkarxyz/2ce772b943321b9448b454f39403ce25/raw"
|
|
||||||
tidalAPIListCacheFile = "tidal-api-urls.json"
|
|
||||||
)
|
|
||||||
|
|
||||||
type tidalAPIListCache struct {
|
|
||||||
URLs []string `json:"urls"`
|
|
||||||
LastUsedURL string `json:"last_used_url,omitempty"`
|
|
||||||
UpdatedAt int64 `json:"updated_at_unix"`
|
|
||||||
Source string `json:"source,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
tidalAPIListMu sync.Mutex
|
|
||||||
tidalAPIListState *tidalAPIListCache
|
|
||||||
)
|
|
||||||
|
|
||||||
func loadTidalAPIListStateLocked() (*tidalAPIListCache, error) {
|
|
||||||
if tidalAPIListState != nil {
|
|
||||||
return cloneTidalAPIListState(tidalAPIListState), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
appDir, err := EnsureAppDir()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
cachePath := filepath.Join(appDir, tidalAPIListCacheFile)
|
|
||||||
data, err := os.ReadFile(cachePath)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
state := &tidalAPIListCache{}
|
|
||||||
tidalAPIListState = cloneTidalAPIListState(state)
|
|
||||||
return cloneTidalAPIListState(state), nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("failed to read tidal api cache: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var state tidalAPIListCache
|
|
||||||
if err := json.Unmarshal(data, &state); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse tidal api cache: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
state.URLs = normalizeTidalAPIURLs(state.URLs)
|
|
||||||
|
|
||||||
tidalAPIListState = cloneTidalAPIListState(&state)
|
|
||||||
return cloneTidalAPIListState(&state), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveTidalAPIListStateLocked(state *tidalAPIListCache) error {
|
|
||||||
appDir, err := EnsureAppDir()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cachePath := filepath.Join(appDir, tidalAPIListCacheFile)
|
|
||||||
payload, err := json.MarshalIndent(state, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to encode tidal api cache: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(cachePath, payload, 0o644); err != nil {
|
|
||||||
return fmt.Errorf("failed to write tidal api cache: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tidalAPIListState = cloneTidalAPIListState(state)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneTidalAPIListState(state *tidalAPIListCache) *tidalAPIListCache {
|
|
||||||
if state == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &tidalAPIListCache{
|
|
||||||
URLs: append([]string(nil), state.URLs...),
|
|
||||||
LastUsedURL: state.LastUsedURL,
|
|
||||||
UpdatedAt: state.UpdatedAt,
|
|
||||||
Source: state.Source,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeTidalAPIURLs(urls []string) []string {
|
|
||||||
seen := make(map[string]struct{}, len(urls))
|
|
||||||
normalized := make([]string, 0, len(urls))
|
|
||||||
|
|
||||||
for _, rawURL := range urls {
|
|
||||||
url := strings.TrimRight(strings.TrimSpace(rawURL), "/")
|
|
||||||
if url == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, exists := seen[url]; exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[url] = struct{}{}
|
|
||||||
normalized = append(normalized, url)
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchTidalAPIURLsFromGist() ([]string, error) {
|
|
||||||
client := &http.Client{Timeout: 12 * time.Second}
|
|
||||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, tidalAPIListGistURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create tidal api gist request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to fetch tidal api gist: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
preview, _ := io.ReadAll(io.LimitReader(resp.Body, 200))
|
|
||||||
return nil, fmt.Errorf("tidal api gist returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(preview)))
|
|
||||||
}
|
|
||||||
|
|
||||||
var urls []string
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&urls); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode tidal api gist: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
urls = normalizeTidalAPIURLs(urls)
|
|
||||||
if len(urls) == 0 {
|
|
||||||
return nil, fmt.Errorf("tidal api gist returned no valid urls")
|
|
||||||
}
|
|
||||||
|
|
||||||
return urls, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func PrimeTidalAPIList() error {
|
|
||||||
_, err := RefreshTidalAPIList(true)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Warning: failed to refresh Tidal API list from gist: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tidalAPIListMu.Lock()
|
|
||||||
defer tidalAPIListMu.Unlock()
|
|
||||||
|
|
||||||
state, loadErr := loadTidalAPIListStateLocked()
|
|
||||||
if loadErr != nil {
|
|
||||||
return loadErr
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(state.URLs) == 0 {
|
|
||||||
return fmt.Errorf("tidal api cache is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
if state.UpdatedAt == 0 {
|
|
||||||
state.UpdatedAt = time.Now().Unix()
|
|
||||||
return saveTidalAPIListStateLocked(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func RefreshTidalAPIList(force bool) ([]string, error) {
|
|
||||||
tidalAPIListMu.Lock()
|
|
||||||
defer tidalAPIListMu.Unlock()
|
|
||||||
|
|
||||||
state, err := loadTidalAPIListStateLocked()
|
|
||||||
if err != nil {
|
|
||||||
state = &tidalAPIListCache{}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !force && len(state.URLs) > 0 {
|
|
||||||
return append([]string(nil), state.URLs...), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
urls, fetchErr := fetchTidalAPIURLsFromGist()
|
|
||||||
if fetchErr != nil {
|
|
||||||
if len(state.URLs) > 0 {
|
|
||||||
return append([]string(nil), state.URLs...), fetchErr
|
|
||||||
}
|
|
||||||
return nil, fetchErr
|
|
||||||
}
|
|
||||||
|
|
||||||
state.URLs = urls
|
|
||||||
state.UpdatedAt = time.Now().Unix()
|
|
||||||
state.Source = "gist"
|
|
||||||
|
|
||||||
if !containsString(state.URLs, state.LastUsedURL) {
|
|
||||||
state.LastUsedURL = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := saveTidalAPIListStateLocked(state); err != nil {
|
|
||||||
return append([]string(nil), state.URLs...), err
|
|
||||||
}
|
|
||||||
|
|
||||||
return append([]string(nil), state.URLs...), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetTidalAPIList() ([]string, error) {
|
|
||||||
tidalAPIListMu.Lock()
|
|
||||||
defer tidalAPIListMu.Unlock()
|
|
||||||
|
|
||||||
state, err := loadTidalAPIListStateLocked()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(state.URLs) == 0 {
|
|
||||||
return nil, fmt.Errorf("no cached tidal api urls")
|
|
||||||
}
|
|
||||||
|
|
||||||
return append([]string(nil), state.URLs...), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetRotatedTidalAPIList() ([]string, error) {
|
|
||||||
tidalAPIListMu.Lock()
|
|
||||||
defer tidalAPIListMu.Unlock()
|
|
||||||
|
|
||||||
state, err := loadTidalAPIListStateLocked()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
urls := state.URLs
|
|
||||||
if len(urls) == 0 {
|
|
||||||
return nil, fmt.Errorf("no cached tidal api urls")
|
|
||||||
}
|
|
||||||
|
|
||||||
return rotateTidalAPIURLs(urls, state.LastUsedURL), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func RememberTidalAPIUsage(apiURL string) error {
|
|
||||||
tidalAPIListMu.Lock()
|
|
||||||
defer tidalAPIListMu.Unlock()
|
|
||||||
|
|
||||||
state, err := loadTidalAPIListStateLocked()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
state.LastUsedURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
|
||||||
if state.UpdatedAt == 0 {
|
|
||||||
state.UpdatedAt = time.Now().Unix()
|
|
||||||
}
|
|
||||||
|
|
||||||
return saveTidalAPIListStateLocked(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
func rotateTidalAPIURLs(urls []string, lastUsedURL string) []string {
|
|
||||||
normalized := normalizeTidalAPIURLs(urls)
|
|
||||||
if len(normalized) < 2 {
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
lastUsedURL = strings.TrimRight(strings.TrimSpace(lastUsedURL), "/")
|
|
||||||
if lastUsedURL == "" {
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
lastIndex := -1
|
|
||||||
for idx, candidate := range normalized {
|
|
||||||
if candidate == lastUsedURL {
|
|
||||||
lastIndex = idx
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if lastIndex == -1 {
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
rotated := make([]string, 0, len(normalized))
|
|
||||||
rotated = append(rotated, normalized[lastIndex+1:]...)
|
|
||||||
rotated = append(rotated, normalized[:lastIndex+1]...)
|
|
||||||
return rotated
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsString(values []string, target string) bool {
|
|
||||||
target = strings.TrimRight(strings.TrimSpace(target), "/")
|
|
||||||
for _, value := range values {
|
|
||||||
if strings.TrimRight(strings.TrimSpace(value), "/") == target {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -14,10 +14,10 @@ async function generateIcon() {
|
|||||||
.resize(1024, 1024)
|
.resize(1024, 1024)
|
||||||
.png()
|
.png()
|
||||||
.toFile(outputPath);
|
.toFile(outputPath);
|
||||||
console.log('✓ Icon generated:', outputPath);
|
console.log('Icon generated:', outputPath);
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
console.error('✗ Failed to generate icon:', error.message);
|
console.error('Failed to generate icon:', error.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+70
-7
@@ -5,12 +5,14 @@ import { Search, X, ArrowUp } from "lucide-react";
|
|||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont } from "@/lib/settings";
|
import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont } from "@/lib/settings";
|
||||||
import { applyTheme } from "@/lib/themes";
|
import { applyTheme } from "@/lib/themes";
|
||||||
|
import { openExternal } from "@/lib/utils";
|
||||||
import { OpenFolder, CheckFFmpegInstalled, DownloadFFmpeg, GetRecentFetches, SaveRecentFetches } from "../wailsjs/go/main/App";
|
import { OpenFolder, CheckFFmpegInstalled, DownloadFFmpeg, GetRecentFetches, SaveRecentFetches } from "../wailsjs/go/main/App";
|
||||||
import { EventsOn, EventsOff, Quit } from "../wailsjs/runtime/runtime";
|
import { EventsOn, EventsOff, Quit } from "../wailsjs/runtime/runtime";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { TitleBar } from "@/components/TitleBar";
|
import { TitleBar } from "@/components/TitleBar";
|
||||||
import { Sidebar, type PageType } from "@/components/Sidebar";
|
import { Sidebar, type PageType } from "@/components/Sidebar";
|
||||||
import { Header } from "@/components/Header";
|
import { Header } from "@/components/Header";
|
||||||
|
import { MarkdownLite, extractMarkdownSection } from "@/components/MarkdownLite";
|
||||||
import { SearchBar } from "@/components/SearchBar";
|
import { SearchBar } from "@/components/SearchBar";
|
||||||
import { TrackInfo } from "@/components/TrackInfo";
|
import { TrackInfo } from "@/components/TrackInfo";
|
||||||
import { AlbumInfo } from "@/components/AlbumInfo";
|
import { AlbumInfo } from "@/components/AlbumInfo";
|
||||||
@@ -22,17 +24,19 @@ import { AudioAnalysisPage } from "@/components/AudioAnalysisPage";
|
|||||||
import { AudioConverterPage } from "@/components/AudioConverterPage";
|
import { AudioConverterPage } from "@/components/AudioConverterPage";
|
||||||
import { AudioResamplerPage } from "@/components/AudioResamplerPage";
|
import { AudioResamplerPage } from "@/components/AudioResamplerPage";
|
||||||
import { FileManagerPage } from "@/components/FileManagerPage";
|
import { FileManagerPage } from "@/components/FileManagerPage";
|
||||||
|
import { LyricsManagerPage } from "@/components/LyricsManagerPage";
|
||||||
import { SettingsPage } from "@/components/SettingsPage";
|
import { SettingsPage } from "@/components/SettingsPage";
|
||||||
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
|
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
|
||||||
import { AboutPage } from "@/components/AboutPage";
|
import { OtherProjects } from "@/components/OtherProjects";
|
||||||
import { HistoryPage } from "@/components/HistoryPage";
|
import { HistoryPage } from "@/components/HistoryPage";
|
||||||
|
import { SupportPage } from "@/components/SupportPage";
|
||||||
import type { HistoryItem } from "@/components/FetchHistory";
|
import type { HistoryItem } from "@/components/FetchHistory";
|
||||||
import { useDownload } from "@/hooks/useDownload";
|
import { useDownload } from "@/hooks/useDownload";
|
||||||
import { useMetadata } from "@/hooks/useMetadata";
|
import { useMetadata } from "@/hooks/useMetadata";
|
||||||
import { useLyrics } from "@/hooks/useLyrics";
|
import { useLyrics } from "@/hooks/useLyrics";
|
||||||
import { useCover } from "@/hooks/useCover";
|
import { useCover } from "@/hooks/useCover";
|
||||||
import { useAvailability } from "@/hooks/useAvailability";
|
import { useAvailability } from "@/hooks/useAvailability";
|
||||||
import { ensureSpotiFLACNextStatusCheckStarted } from "@/lib/api-status";
|
import { ensureApiStatusCheckStarted } from "@/lib/api-status";
|
||||||
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
|
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
|
||||||
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
||||||
import { buildPlaylistFolderName } from "@/lib/playlist";
|
import { buildPlaylistFolderName } from "@/lib/playlist";
|
||||||
@@ -133,6 +137,12 @@ function App() {
|
|||||||
const [currentListPage, setCurrentListPage] = useState(1);
|
const [currentListPage, setCurrentListPage] = useState(1);
|
||||||
const [hasUpdate, setHasUpdate] = useState(false);
|
const [hasUpdate, setHasUpdate] = useState(false);
|
||||||
const [releaseDate, setReleaseDate] = useState<string | null>(null);
|
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 [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
|
||||||
const [isSearchMode, setIsSearchMode] = useState(false);
|
const [isSearchMode, setIsSearchMode] = useState(false);
|
||||||
const [region, setRegion] = useState(() => localStorage.getItem("spotiflac_region") || "US");
|
const [region, setRegion] = useState(() => localStorage.getItem("spotiflac_region") || "US");
|
||||||
@@ -198,7 +208,7 @@ function App() {
|
|||||||
};
|
};
|
||||||
mediaQuery.addEventListener("change", handleChange);
|
mediaQuery.addEventListener("change", handleChange);
|
||||||
checkForUpdates();
|
checkForUpdates();
|
||||||
ensureSpotiFLACNextStatusCheckStarted();
|
ensureApiStatusCheckStarted();
|
||||||
void loadHistory();
|
void loadHistory();
|
||||||
return () => {
|
return () => {
|
||||||
mediaQuery.removeEventListener("change", handleChange);
|
mediaQuery.removeEventListener("change", handleChange);
|
||||||
@@ -237,14 +247,24 @@ function App() {
|
|||||||
}, [metadata.metadata]);
|
}, [metadata.metadata]);
|
||||||
const checkForUpdates = async () => {
|
const checkForUpdates = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("https://api.github.com/repos/afkarxyz/SpotiFLAC/releases/latest");
|
const response = await fetch("https://api.github.com/repos/spotbye/SpotiFLAC/releases/latest");
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const latestVersion = data.tag_name?.replace(/^v/, "") || "";
|
const rawTag = data.tag_name || "";
|
||||||
|
const latestVersion = rawTag.replace(/^v/, "") || "";
|
||||||
if (data.published_at) {
|
if (data.published_at) {
|
||||||
setReleaseDate(data.published_at);
|
setReleaseDate(data.published_at);
|
||||||
}
|
}
|
||||||
if (latestVersion && latestVersion > CURRENT_VERSION) {
|
if (latestVersion && latestVersion > CURRENT_VERSION) {
|
||||||
setHasUpdate(true);
|
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) {
|
catch (err) {
|
||||||
@@ -362,6 +382,7 @@ function App() {
|
|||||||
name: track.name,
|
name: track.name,
|
||||||
artist: track.artists,
|
artist: track.artists,
|
||||||
image: track.images,
|
image: track.images,
|
||||||
|
is_explicit: track.is_explicit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
else if ("album_info" in metadata.metadata) {
|
else if ("album_info" in metadata.metadata) {
|
||||||
@@ -372,6 +393,7 @@ function App() {
|
|||||||
name: album_info.name,
|
name: album_info.name,
|
||||||
artist: `${album_info.total_tracks.toLocaleString()} tracks`,
|
artist: `${album_info.total_tracks.toLocaleString()} tracks`,
|
||||||
image: album_info.images,
|
image: album_info.images,
|
||||||
|
is_explicit: album_info.is_explicit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
else if ("playlist_info" in metadata.metadata) {
|
else if ("playlist_info" in metadata.metadata) {
|
||||||
@@ -528,8 +550,10 @@ function App() {
|
|||||||
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn}/>;
|
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn}/>;
|
||||||
case "debug":
|
case "debug":
|
||||||
return <DebugLoggerPage />;
|
return <DebugLoggerPage />;
|
||||||
case "about":
|
case "projects":
|
||||||
return <AboutPage />;
|
return <OtherProjects />;
|
||||||
|
case "support":
|
||||||
|
return <SupportPage />;
|
||||||
case "history":
|
case "history":
|
||||||
return <HistoryPage onHistorySelect={(cachedData) => {
|
return <HistoryPage onHistorySelect={(cachedData) => {
|
||||||
metadata.loadFromCache(cachedData);
|
metadata.loadFromCache(cachedData);
|
||||||
@@ -543,6 +567,8 @@ function App() {
|
|||||||
return <AudioResamplerPage />;
|
return <AudioResamplerPage />;
|
||||||
case "file-manager":
|
case "file-manager":
|
||||||
return <FileManagerPage />;
|
return <FileManagerPage />;
|
||||||
|
case "lyrics-manager":
|
||||||
|
return <LyricsManagerPage />;
|
||||||
default:
|
default:
|
||||||
return (<>
|
return (<>
|
||||||
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} releaseDate={releaseDate}/>
|
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} releaseDate={releaseDate}/>
|
||||||
@@ -623,6 +649,43 @@ function App() {
|
|||||||
</Button>)}
|
</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}>
|
<Dialog open={showUnsavedChangesDialog} onOpenChange={setShowUnsavedChangesDialog}>
|
||||||
<DialogContent className="sm:max-w-106.25 [&>button]:hidden">
|
<DialogContent className="sm:max-w-106.25 [&>button]:hidden">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 749.3 227.1" fill="#FFFFFF">
|
||||||
|
<!-- Generator: Adobe Illustrator 29.3.1, SVG Export Plug-In . SVG Version: 2.1.0 Build 151) -->
|
||||||
|
<path d="M222.8,85.4c0-3.4,2.5-5.6,6.4-5.6h18.6c16.9,0,28.3,9.3,28.3,22.9s-11.3,23.3-28.3,23.3h-2.6c-6.5,0-9.8,3.4-9.8,8.8v15.2c0,4.3-2.5,7-6.3,7s-6.3-2.7-6.3-7v-64.6ZM235.4,104.7c0,6.8,3.5,10.1,10.1,10.1h1.6c9.3,0,16.1-3.8,16.1-12.1s-6.8-12.1-16.1-12.1h-1.6c-6.6,0-10.1,3.2-10.1,10.1v4.1ZM276.1,151.1c0,3.6,2.5,5.9,6.3,5.9s4.8-1.6,6.1-5l2.3-6.1c1.8-4.9,5.1-7.1,8.6-7.1h20.5c3.6,0,6.8,2.3,8.6,7.1l2.3,6.1c1.3,3.4,3.6,5,6.1,5,3.8,0,6.3-2.4,6.3-5.9s-.2-2.2-.6-3.4l-24.5-63.8c-1.5-3.9-5-5.8-8.3-5.8s-6.8,1.9-8.3,5.8l-24.5,63.8c-.4,1.2-.6,2.4-.6,3.4ZM300,122.1c0-1.2.3-2.3.9-3.9l4.6-12.9c.9-2.5,2.4-3.7,4.1-3.7s3.2,1.2,4.1,3.7l4.6,12.9c.5,1.6.9,2.7.9,3.9,0,3.2-1.8,5.5-6.7,5.5h-5.8c-4.9,0-6.7-2.3-6.7-5.5ZM339,85.6c0-3.5,2.5-5.8,6.5-5.8h49.7c4,0,6.5,2.4,6.5,5.8s-2.5,5.8-6.5,5.8h-8.3c-6.6,0-10.2,3.4-10.2,11v47.4c0,4.4-2.5,7.1-6.4,7.1s-6.4-2.7-6.4-7.1v-47.4c0-7.7-3.6-11-10.2-11h-8.3c-4,0-6.5-2.4-6.5-5.8ZM413.4,150c0,4.3,2.5,7,6.3,7s6.3-2.7,6.3-7v-17.2c0-4.9,2.8-6.9,6.3-6.9h.9c2.3,0,4.5,1.4,5.9,3.5l16.4,24.1c1.5,2.3,3.5,3.6,5.9,3.6s5.8-2.7,5.8-5.9-.4-2.7-1.4-4.1l-10.9-15.3c-1.3-1.8-1.8-3.4-1.8-4.6,0-2.7,2.4-4.6,5.2-6.7,5.1-3.8,10.6-8.8,10.6-18.3s-10.4-22.3-27.5-22.3h-21.7c-3.9,0-6.3,2.3-6.3,5.6v64.6ZM425.9,103.7v-3.2c0-7,3.7-9.9,9.3-9.9h5.4c9.3,0,15.2,3.5,15.2,11.5s-6.3,11.7-15.6,11.7h-5.1c-5.6,0-9.3-2.9-9.3-9.9ZM484.8,149.8v-64.4c0-3.4,2.4-5.6,6.3-5.6h40.9c3.9,0,6.3,2.3,6.3,5.6s-2.4,5.6-6.3,5.6h-25.8c-5.1,0-8.8,3-8.8,8.8v2.4c0,5.7,3.7,8.8,8.8,8.8h20c3.9,0,6.3,2.3,6.3,5.6s-2.4,5.6-6.3,5.6h-19.2c-5.1,0-9.5,3.1-9.5,9.5v3c0,6.4,4.4,9.5,9.5,9.5h25.1c3.9,0,6.3,2.3,6.3,5.6s-2.4,5.6-6.3,5.6h-40.9c-3.9,0-6.3-2.3-6.3-5.6ZM545.7,117.6c0-23.3,17.5-39.4,38-39.4s38,16.1,38,39.4-17.5,39.4-38,39.4-38-16.1-38-39.4ZM559.9,117.6c0,16.4,9.7,26.9,23.8,26.9s23.8-10.5,23.8-26.9-9.7-26.9-23.8-26.9-23.8,10.4-23.8,26.9ZM636.8,150c0,4.3,2.5,7,6.3,7s6.3-2.7,6.3-7v-33.1c0-4,2.4-5.9,4.9-5.9s3.6,1.1,4.8,3l20.8,34.7c2.8,4.8,5.4,8.3,10.7,8.3s8.8-3.7,8.8-9.6v-62.2c0-4.3-2.5-7-6.3-7s-6.3,2.7-6.3,7v33.1c0,4-2.4,5.9-4.9,5.9s-3.6-1.1-4.8-3l-20.8-34.7c-2.8-4.8-5.4-8.3-10.7-8.3s-8.8,3.7-8.8,9.6v62.2Z"/>
|
||||||
|
<path d="M169.2,87.5c0-16.7-13-30.3-28.2-35.2-18.9-6.1-43.8-5.2-61.9,3.3-21.9,10.3-28.7,32.9-29,55.5-.2,18.5,1.6,67.4,29.2,67.7,20.5.3,23.5-26.1,33-38.8,6.7-9,15.4-11.6,26.1-14.2,18.4-4.5,30.9-19,30.8-38.2Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 27.4.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 1080 1080" style="enable-background:new 0 0 1080 1080;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#FFFFFF;}
|
||||||
|
</style>
|
||||||
|
<path class="st0" d="M1033.05,324.45c-0.19-137.9-107.59-250.92-233.6-291.7c-156.48-50.64-362.86-43.3-512.28,27.2
|
||||||
|
C106.07,145.41,49.18,332.61,47.06,519.31c-1.74,153.5,13.58,557.79,241.62,560.67c169.44,2.15,194.67-216.18,273.07-321.33
|
||||||
|
c55.78-74.81,127.6-95.94,216.01-117.82C929.71,603.22,1033.27,483.3,1033.05,324.45z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 735 B |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -12,7 +12,7 @@ import { useState } from "react";
|
|||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { joinPath, sanitizePath } from "@/lib/utils";
|
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||||
import { parseTemplate, type TemplateData } from "@/lib/settings";
|
import { parseTemplate, type TemplateData } from "@/lib/settings";
|
||||||
import { buildClickableArtists, splitArtistNames } from "@/lib/artist-links";
|
import { buildClickableArtists, splitArtistNames, getClickableArtistKey } from "@/lib/artist-links";
|
||||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||||
interface AlbumInfoProps {
|
interface AlbumInfoProps {
|
||||||
albumInfo: {
|
albumInfo: {
|
||||||
@@ -21,6 +21,7 @@ interface AlbumInfoProps {
|
|||||||
images: string;
|
images: string;
|
||||||
release_date: string;
|
release_date: string;
|
||||||
total_tracks: number;
|
total_tracks: number;
|
||||||
|
is_explicit?: boolean;
|
||||||
artist_id?: string;
|
artist_id?: string;
|
||||||
artist_url?: string;
|
artist_url?: string;
|
||||||
};
|
};
|
||||||
@@ -206,18 +207,21 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
|
|||||||
</div>)}
|
</div>)}
|
||||||
<div className="flex-1 space-y-4">
|
<div className="flex-1 space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-medium">Album</p>
|
<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>
|
||||||
<h2 className="text-4xl font-bold">{albumInfo.name}</h2>
|
<h2 className="text-4xl font-bold">{albumInfo.name}</h2>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{clickableAlbumArtists.length > 0 ? clickableAlbumArtists.map((artist, index) => (<span key={`${artist.id || artist.name}-${index}`}>
|
{clickableAlbumArtists.length > 0 ? clickableAlbumArtists.map((artist, index) => (<span key={getClickableArtistKey(artist)}>
|
||||||
{onArtistClick && artist.external_urls ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
|
{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({
|
||||||
id: artist.id,
|
id: artist.id,
|
||||||
name: artist.name,
|
name: artist.name,
|
||||||
external_urls: artist.external_urls,
|
external_urls: artist.external_urls,
|
||||||
})}>
|
})}>
|
||||||
{artist.name}
|
{artist.name}
|
||||||
</span>) : (artist.name)}
|
</button>) : (artist.name)}
|
||||||
{index < clickableAlbumArtists.length - 1 && artistSeparator}
|
{index < clickableAlbumArtists.length - 1 && artistSeparator}
|
||||||
</span>)) : albumInfo.artists}
|
</span>)) : albumInfo.artists}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { SearchCheck, CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
import { PlugZap, CheckCircle2, Loader2, Wrench, Server } from "lucide-react";
|
||||||
import { TidalIcon, QobuzIcon, AmazonIcon, MusicBrainzIcon, AppleMusicIcon, DeezerIcon } from "./PlatformIcons";
|
import { TidalIcon, QobuzIcon, AmazonIcon, AppleMusicIcon, DeezerIcon } from "./PlatformIcons";
|
||||||
import { useApiStatus } from "@/hooks/useApiStatus";
|
import { useApiStatus } from "@/hooks/useApiStatus";
|
||||||
import { SPOTIFLAC_NEXT_SOURCES } from "@/lib/api-status";
|
import { SPOTIFLAC_NEXT_SOURCES } from "@/lib/api-status";
|
||||||
function renderStatusIcon(status: "checking" | "online" | "offline" | "idle") {
|
import { openExternal } from "@/lib/utils";
|
||||||
|
function renderStatusIndicator(status: "checking" | "online" | "offline" | "idle") {
|
||||||
if (status === "online") {
|
if (status === "online") {
|
||||||
return <CheckCircle2 className="h-5 w-5 text-emerald-500"/>;
|
return <CheckCircle2 className="h-5 w-5 text-emerald-500"/>;
|
||||||
}
|
}
|
||||||
if (status === "offline") {
|
if (status === "offline") {
|
||||||
return <XCircle className="h-5 w-5 text-destructive"/>;
|
return <Wrench className="h-4 w-4 text-amber-600 dark:text-amber-400"/>;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -19,9 +20,6 @@ function renderPlatformIcon(type: string) {
|
|||||||
if (type === "amazon") {
|
if (type === "amazon") {
|
||||||
return <AmazonIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
return <AmazonIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
||||||
}
|
}
|
||||||
if (type === "musicbrainz") {
|
|
||||||
return <MusicBrainzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
|
||||||
}
|
|
||||||
if (type === "deezer") {
|
if (type === "deezer") {
|
||||||
return <DeezerIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
return <DeezerIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
||||||
}
|
}
|
||||||
@@ -31,27 +29,41 @@ function renderPlatformIcon(type: string) {
|
|||||||
return <QobuzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
return <QobuzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
||||||
}
|
}
|
||||||
export function ApiStatusTab() {
|
export function ApiStatusTab() {
|
||||||
const { sources, statuses, nextStatuses, checkingSources, checkOne } = useApiStatus();
|
const { sources, statuses, nextStatuses, checkingSources, checkAllCurrent, checkAllNext } = useApiStatus();
|
||||||
|
const isCheckingCurrent = sources.some((source) => checkingSources[source.id] === true);
|
||||||
|
const isCheckingNext = SPOTIFLAC_NEXT_SOURCES.some((source) => nextStatuses[source.id] === "checking");
|
||||||
|
const isChecking = isCheckingCurrent || isCheckingNext;
|
||||||
|
const checkAll = () => {
|
||||||
|
void checkAllCurrent();
|
||||||
|
void checkAllNext();
|
||||||
|
};
|
||||||
return (<div className="space-y-6">
|
return (<div className="space-y-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Services</h3>
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC</h3>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{sources.map((source) => {
|
{sources.map((source) => {
|
||||||
const status = statuses[source.id] || "idle";
|
const status = statuses[source.id] || "idle";
|
||||||
const isChecking = checkingSources[source.id] === true;
|
|
||||||
return (<div key={source.id} className="space-y-4 p-4 border rounded-lg bg-card text-card-foreground shadow-sm">
|
return (<div key={source.id} className="space-y-4 p-4 border rounded-lg bg-card text-card-foreground shadow-sm">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{renderPlatformIcon(source.type)}
|
{renderPlatformIcon(source.type)}
|
||||||
<p className="font-medium leading-none">{source.name}</p>
|
<p className="font-medium leading-none">{source.name}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">{renderStatusIcon(status)}</div>
|
<div className="flex items-center">{renderStatusIndicator(status)}</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => void checkOne(source.id)} disabled={isChecking} className="w-full gap-2">
|
|
||||||
{isChecking ? <Loader2 className="h-4 w-4 animate-spin"/> : <SearchCheck className="h-4 w-4"/>}
|
|
||||||
Check
|
|
||||||
</Button>
|
|
||||||
</div>);
|
</div>);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -60,7 +72,7 @@ export function ApiStatusTab() {
|
|||||||
<div className="border-t"/>
|
<div className="border-t"/>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Next Services</h3>
|
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Next</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
{SPOTIFLAC_NEXT_SOURCES.map((source) => {
|
{SPOTIFLAC_NEXT_SOURCES.map((source) => {
|
||||||
@@ -70,7 +82,7 @@ export function ApiStatusTab() {
|
|||||||
{renderPlatformIcon(source.id)}
|
{renderPlatformIcon(source.id)}
|
||||||
<p className="font-medium leading-none">{source.name}</p>
|
<p className="font-medium leading-none">{source.name}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">{renderStatusIcon(status)}</div>
|
<div className="flex items-center">{renderStatusIndicator(status)}</div>
|
||||||
</div>);
|
</div>);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ interface ArtistInfoProps {
|
|||||||
album_type: string;
|
album_type: string;
|
||||||
external_urls: string;
|
external_urls: string;
|
||||||
total_tracks?: number;
|
total_tracks?: number;
|
||||||
|
is_explicit?: boolean;
|
||||||
}>;
|
}>;
|
||||||
trackList: TrackMetadata[];
|
trackList: TrackMetadata[];
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -475,7 +476,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
<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={index} className="relative group">
|
{artistInfo.gallery!.map((imageUrl, index) => (<div key={`${imageUrl}-${index}`} className="relative group">
|
||||||
<div className="relative aspect-square rounded-md overflow-hidden shadow-md">
|
<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"/>
|
<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">
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors flex items-center justify-center">
|
||||||
@@ -537,7 +538,10 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h4 className="font-semibold truncate text-sm">{album.name}</h4>
|
<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>
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-1">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-1">
|
||||||
<span>{album.release_date?.split("-")[0]}</span>
|
<span>{album.release_date?.split("-")[0]}</span>
|
||||||
{album.total_tracks && (<>
|
{album.total_tracks && (<>
|
||||||
|
|||||||
@@ -51,12 +51,12 @@ export function AudioConverterPage() {
|
|||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
const [outputFormat, setOutputFormat] = useState<"mp3" | "m4a">(() => {
|
const [outputFormat, setOutputFormat] = useState<"mp3" | "m4a" | "wav" | "aiff" | "opus">(() => {
|
||||||
try {
|
try {
|
||||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||||
if (saved) {
|
if (saved) {
|
||||||
const parsed = JSON.parse(saved);
|
const parsed = JSON.parse(saved);
|
||||||
if (parsed.outputFormat === "mp3" || parsed.outputFormat === "m4a") {
|
if (["mp3", "m4a", "wav", "aiff", "opus"].includes(parsed.outputFormat)) {
|
||||||
return parsed.outputFormat;
|
return parsed.outputFormat;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,7 +98,7 @@ export function AudioConverterPage() {
|
|||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const saveState = useCallback((stateToSave: {
|
const saveState = useCallback((stateToSave: {
|
||||||
files: AudioFile[];
|
files: AudioFile[];
|
||||||
outputFormat: "mp3" | "m4a";
|
outputFormat: "mp3" | "m4a" | "wav" | "aiff" | "opus";
|
||||||
bitrate: string;
|
bitrate: string;
|
||||||
m4aCodec: "aac" | "alac";
|
m4aCodec: "aac" | "alac";
|
||||||
}) => {
|
}) => {
|
||||||
@@ -116,7 +116,7 @@ export function AudioConverterPage() {
|
|||||||
if (files.length === 0)
|
if (files.length === 0)
|
||||||
return;
|
return;
|
||||||
const allMP3 = files.every((f) => f.format === "mp3");
|
const allMP3 = files.every((f) => f.format === "mp3");
|
||||||
if (allMP3 && outputFormat !== "m4a") {
|
if (allMP3 && outputFormat === "mp3") {
|
||||||
setOutputFormat("m4a");
|
setOutputFormat("m4a");
|
||||||
}
|
}
|
||||||
const hasFlac = files.some((f) => f.format === "flac");
|
const hasFlac = files.some((f) => f.format === "flac");
|
||||||
@@ -375,15 +375,24 @@ export function AudioConverterPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="whitespace-nowrap">Format:</Label>
|
<Label className="whitespace-nowrap">Format:</Label>
|
||||||
<ToggleGroup type="single" variant="outline" value={outputFormat} onValueChange={(value) => {
|
<ToggleGroup type="single" variant="outline" value={outputFormat} onValueChange={(value) => {
|
||||||
if (value && !isFormatDisabled)
|
if (value)
|
||||||
setOutputFormat(value as "mp3" | "m4a");
|
setOutputFormat(value as "mp3" | "m4a" | "wav" | "aiff" | "opus");
|
||||||
}} disabled={isFormatDisabled}>
|
}}>
|
||||||
{!isFormatDisabled && (<ToggleGroupItem value="mp3" aria-label="MP3">
|
{!isFormatDisabled && (<ToggleGroupItem value="mp3" aria-label="MP3">
|
||||||
MP3
|
MP3
|
||||||
</ToggleGroupItem>)}
|
</ToggleGroupItem>)}
|
||||||
<ToggleGroupItem value="m4a" aria-label="M4A" disabled={isFormatDisabled}>
|
<ToggleGroupItem value="m4a" aria-label="M4A">
|
||||||
M4A
|
M4A
|
||||||
</ToggleGroupItem>
|
</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>
|
</ToggleGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -399,7 +408,7 @@ export function AudioConverterPage() {
|
|||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
{!(outputFormat === "m4a" && m4aCodec === "alac") && (<div className="flex items-center gap-2">
|
{(outputFormat === "mp3" || outputFormat === "opus" || (outputFormat === "m4a" && m4aCodec === "aac")) && (<div className="flex items-center gap-2">
|
||||||
<Label className="whitespace-nowrap">Bitrate:</Label>
|
<Label className="whitespace-nowrap">Bitrate:</Label>
|
||||||
<ToggleGroup type="single" variant="outline" value={bitrate} onValueChange={(value) => {
|
<ToggleGroup type="single" variant="outline" value={bitrate} onValueChange={(value) => {
|
||||||
if (value)
|
if (value)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { StopCircle } from "lucide-react";
|
import { StopCircle, Clock } from "lucide-react";
|
||||||
|
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
||||||
interface DownloadProgressProps {
|
interface DownloadProgressProps {
|
||||||
progress: number;
|
progress: number;
|
||||||
remainingCount?: number;
|
remainingCount?: number;
|
||||||
@@ -11,6 +12,9 @@ interface DownloadProgressProps {
|
|||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
}
|
}
|
||||||
export function DownloadProgress({ progress, remainingCount = 0, currentTrack, onStop }: DownloadProgressProps) {
|
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;
|
||||||
const clampedProgress = Math.min(100, Math.max(0, progress));
|
const clampedProgress = Math.min(100, Math.max(0, progress));
|
||||||
const safeRemainingCount = Math.max(0, remainingCount);
|
const safeRemainingCount = Math.max(0, remainingCount);
|
||||||
const remainingLabel = `${safeRemainingCount.toLocaleString()} ${safeRemainingCount === 1 ? "track" : "tracks"} left`;
|
const remainingLabel = `${safeRemainingCount.toLocaleString()} ${safeRemainingCount === 1 ? "track" : "tracks"} left`;
|
||||||
@@ -22,11 +26,14 @@ export function DownloadProgress({ progress, remainingCount = 0, currentTrack, o
|
|||||||
Stop
|
Stop
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
{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} -{" "}
|
{clampedProgress}% • {remainingLabel} -{" "}
|
||||||
{currentTrack
|
{currentTrack
|
||||||
? `${currentTrack.name} - ${currentTrack.artists}`
|
? `${currentTrack.name} - ${currentTrack.artists}`
|
||||||
: "Preparing download..."}
|
: "Preparing download..."}
|
||||||
</p>
|
</p>)}
|
||||||
</div>);
|
</div>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface HistoryItem {
|
|||||||
name: string;
|
name: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
image: string;
|
image: string;
|
||||||
|
is_explicit?: boolean;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
interface FetchHistoryProps {
|
interface FetchHistoryProps {
|
||||||
@@ -75,9 +76,12 @@ export function FetchHistory({ history, onSelect, onRemove }: FetchHistoryProps)
|
|||||||
</div>)}
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<p className="text-xs font-medium truncate" title={item.name}>
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
{item.name}
|
{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>
|
<p className="min-w-0 text-xs font-medium truncate" title={item.name}>
|
||||||
|
{item.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<p className="text-xs text-muted-foreground truncate" title={item.artist}>
|
<p className="text-xs text-muted-foreground truncate" title={item.artist}>
|
||||||
{item.artist}
|
{item.artist}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -11,9 +11,13 @@ export function Header({ version, hasUpdate, releaseDate }: HeaderProps) {
|
|||||||
return (<div className="relative">
|
return (<div className="relative">
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<div className="flex items-center justify-center gap-3">
|
<div className="flex items-center justify-center gap-3">
|
||||||
<img src="/icon.svg" alt="SpotiFLAC" className="w-12 h-12 cursor-pointer" onClick={() => window.location.reload()}/>
|
<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">
|
||||||
<h1 className="text-4xl font-bold cursor-pointer" onClick={() => window.location.reload()}>
|
<img src="/icon.svg" alt="" className="w-12 h-12"/>
|
||||||
SpotiFLAC
|
</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>
|
||||||
</h1>
|
</h1>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ interface FetchHistoryItem {
|
|||||||
info: string;
|
info: string;
|
||||||
image: string;
|
image: string;
|
||||||
data: string;
|
data: string;
|
||||||
|
is_explicit?: boolean;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
interface HistoryPageProps {
|
interface HistoryPageProps {
|
||||||
@@ -566,7 +567,10 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
{item.type.slice(0, 2).toUpperCase()}
|
{item.type.slice(0, 2).toUpperCase()}
|
||||||
</div>)}
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium text-sm truncate">{item.name}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
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>);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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>;
|
||||||
|
}
|
||||||
@@ -1,24 +1,18 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { openExternal } from "@/lib/utils";
|
import { openExternal } from "@/lib/utils";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
|
||||||
import { Star, GitFork, Clock, Download, Blocks, Heart, Copy, CircleCheck, Info } from "lucide-react";
|
import { Star, GitFork, Clock, Download, Info } from "lucide-react";
|
||||||
import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
|
import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
|
||||||
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
|
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
|
||||||
import XIcon from "@/assets/x.webp";
|
import XIcon from "@/assets/x.webp";
|
||||||
import XProIcon from "@/assets/x-pro.webp";
|
|
||||||
import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
|
import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
|
||||||
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
|
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
|
||||||
import SpotiFLACNextIcon from "@/assets/icons/next.svg";
|
import SpotiFLACNextIcon from "@/assets/icons/next.svg";
|
||||||
import KofiLogo from "@/assets/ko-fi.gif";
|
|
||||||
import KofiSvg from "@/assets/kofi_symbol.svg";
|
|
||||||
import UsdtBarcode from "@/assets/usdt.jpg";
|
|
||||||
import { langColors } from "@/assets/github-lang-colors";
|
import { langColors } from "@/assets/github-lang-colors";
|
||||||
const browserExtensionItems = [
|
const browserExtensionItems = [
|
||||||
{ icon: AudioTTSProIcon, label: "AudioTTS Pro", alt: "AudioTTS Pro" },
|
{ icon: AudioTTSProIcon, label: "AudioTTS Pro", alt: "AudioTTS Pro" },
|
||||||
{ icon: ChatGPTTTSIcon, label: "ChatGPT TTS", alt: "ChatGPT TTS" },
|
{ icon: ChatGPTTTSIcon, label: "ChatGPT TTS", alt: "ChatGPT TTS" },
|
||||||
{ icon: XIcon, label: "Twitter/X Media Batch Downloader", alt: "Twitter/X Media Batch Downloader" },
|
{ icon: XIcon, label: "Twitter/X Media Batch Downloader", alt: "Twitter/X Media Batch Downloader" },
|
||||||
{ icon: XProIcon, label: "Twitter/X Media Batch Downloader Pro", alt: "Twitter/X Media Batch Downloader Pro" },
|
|
||||||
];
|
];
|
||||||
const projectCardClass = "cursor-pointer gap-3 py-5 transition-colors hover:bg-muted/50 dark:hover:bg-accent/50";
|
const projectCardClass = "cursor-pointer gap-3 py-5 transition-colors hover:bg-muted/50 dark:hover:bg-accent/50";
|
||||||
const projectCardHeaderClass = "px-5 gap-1.5";
|
const projectCardHeaderClass = "px-5 gap-1.5";
|
||||||
@@ -26,10 +20,8 @@ const projectCardContentClass = "px-5";
|
|||||||
const projectBodyClass = "text-[13px] leading-snug";
|
const projectBodyClass = "text-[13px] leading-snug";
|
||||||
const releaseMetaClass = "text-xs text-muted-foreground whitespace-nowrap";
|
const releaseMetaClass = "text-xs text-muted-foreground whitespace-nowrap";
|
||||||
const releaseVersionClass = "text-xs bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold whitespace-nowrap";
|
const releaseVersionClass = "text-xs bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold whitespace-nowrap";
|
||||||
export function AboutPage() {
|
export function OtherProjects() {
|
||||||
const [activeTab, setActiveTab] = useState<"projects" | "support">("projects");
|
|
||||||
const [repoStats, setRepoStats] = useState<Record<string, any>>({});
|
const [repoStats, setRepoStats] = useState<Record<string, any>>({});
|
||||||
const [copiedUsdt, setCopiedUsdt] = useState(false);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchRepoStats = async () => {
|
const fetchRepoStats = async () => {
|
||||||
const CACHE_KEY = "github_repo_stats_v4";
|
const CACHE_KEY = "github_repo_stats_v4";
|
||||||
@@ -181,24 +173,10 @@ export function AboutPage() {
|
|||||||
};
|
};
|
||||||
return (<div className="flex flex-col space-y-3">
|
return (<div className="flex flex-col space-y-3">
|
||||||
<div className="flex items-center justify-between shrink-0">
|
<div className="flex items-center justify-between shrink-0">
|
||||||
<h2 className="text-2xl font-bold tracking-tight">About</h2>
|
<h2 className="text-2xl font-bold tracking-tight">Other Projects</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 border-b shrink-0">
|
<div className="flex-1 min-h-0 pr-1.5">
|
||||||
<Button variant={activeTab === "projects" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("projects")} className="rounded-b-none">
|
|
||||||
<Blocks className="h-4 w-4"/>
|
|
||||||
Other Projects
|
|
||||||
</Button>
|
|
||||||
<Button variant={activeTab === "support" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("support")} className="rounded-b-none">
|
|
||||||
<Heart className="h-4 w-4"/>
|
|
||||||
Support Me
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-h-0">
|
|
||||||
|
|
||||||
|
|
||||||
{activeTab === "projects" && (<div className="pr-1.5">
|
|
||||||
<div className="grid gap-2 grid-cols-3">
|
<div className="grid gap-2 grid-cols-3">
|
||||||
<Card className={projectCardClass} onClick={() => openExternal("https://github.com/spotbye/SpotiFLAC-Next")}>
|
<Card className={projectCardClass} onClick={() => openExternal("https://github.com/spotbye/SpotiFLAC-Next")}>
|
||||||
<CardHeader className={projectCardHeaderClass}>
|
<CardHeader className={projectCardHeaderClass}>
|
||||||
@@ -223,9 +201,9 @@ export function AboutPage() {
|
|||||||
{repoStats["SpotiFLAC-Next"] && (<CardContent className={`${projectCardContentClass} space-y-2`}>
|
{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?.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={{
|
{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",
|
backgroundColor: getLangColor(lang) + "20",
|
||||||
color: getLangColor(lang),
|
color: getLangColor(lang),
|
||||||
}}>
|
}}>
|
||||||
{lang}
|
{lang}
|
||||||
</span>))}
|
</span>))}
|
||||||
</div>)}
|
</div>)}
|
||||||
@@ -249,7 +227,7 @@ export function AboutPage() {
|
|||||||
Note
|
Note
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs leading-snug text-sky-700 dark:text-sky-300">
|
<p className="text-xs leading-snug text-sky-700 dark:text-sky-300">
|
||||||
This project released as a token of appreciation for those who have supported SpotiFLAC on Ko-fi. It’s not a paid product, but it’s shared privately through a supporter-only post.
|
This project released as a token of appreciation for those who have supported SpotiFLAC through Ko-fi, Patreon, or crypto. It’s not a paid product, but it’s shared privately through a supporter-only post.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>)}
|
</CardContent>)}
|
||||||
@@ -277,9 +255,9 @@ export function AboutPage() {
|
|||||||
{repoStats["Twitter-X-Media-Batch-Downloader"] && (<CardContent className={`${projectCardContentClass} space-y-2`}>
|
{repoStats["Twitter-X-Media-Batch-Downloader"] && (<CardContent className={`${projectCardContentClass} space-y-2`}>
|
||||||
<div className="flex flex-wrap gap-2 text-xs">
|
<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={{
|
{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",
|
backgroundColor: getLangColor(lang) + "20",
|
||||||
color: getLangColor(lang),
|
color: getLangColor(lang),
|
||||||
}}>
|
}}>
|
||||||
{lang}
|
{lang}
|
||||||
</span>))}
|
</span>))}
|
||||||
</div>
|
</div>
|
||||||
@@ -295,25 +273,25 @@ export function AboutPage() {
|
|||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Clock className="h-3.5 w-3.5"/>{" "}
|
<Clock className="h-3.5 w-3.5"/>{" "}
|
||||||
{formatTimeAgo(repoStats["Twitter-X-Media-Batch-Downloader"]
|
{formatTimeAgo(repoStats["Twitter-X-Media-Batch-Downloader"]
|
||||||
.createdAt)}
|
.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
|
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Download className="h-3.5 w-3.5"/> TOTAL:{" "}
|
<Download className="h-3.5 w-3.5"/> TOTAL:{" "}
|
||||||
{formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"]
|
{formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"]
|
||||||
.totalDownloads)}
|
.totalDownloads)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||||
<Download className="h-3.5 w-3.5"/> LATEST:{" "}
|
<Download className="h-3.5 w-3.5"/> LATEST:{" "}
|
||||||
{formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"]
|
{formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"]
|
||||||
.latestDownloads)}
|
.latestDownloads)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>)}
|
</CardContent>)}
|
||||||
</Card>
|
</Card>
|
||||||
<div className="flex h-full flex-col gap-1.5">
|
<div className="flex h-full flex-col gap-1.5">
|
||||||
<Card className={`${projectCardClass} flex-1`} onClick={() => openExternal("https://exyezed.qzz.io/")}>
|
<Card className={`${projectCardClass} flex-1`} onClick={() => openExternal("https://exyezed.fyi/")}>
|
||||||
<CardHeader className={projectCardHeaderClass}>
|
<CardHeader className={projectCardHeaderClass}>
|
||||||
<CardTitle className="leading-tight">Browser Extensions & Scripts</CardTitle>
|
<CardTitle className="leading-tight">Browser Extensions & Scripts</CardTitle>
|
||||||
<CardDescription className="flex flex-col gap-2.5 pt-1.5">
|
<CardDescription className="flex flex-col gap-2.5 pt-1.5">
|
||||||
@@ -339,55 +317,6 @@ export function AboutPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
|
||||||
|
|
||||||
{activeTab === "support" && (<div className="flex flex-col items-center justify-center p-4 space-y-6">
|
|
||||||
<div className="flex flex-col md:flex-row w-full max-w-3xl bg-card rounded-xl border shadow-sm">
|
|
||||||
|
|
||||||
<div className="flex-1 p-6 flex flex-col items-center justify-between border-b md:border-b-0 md:border-r space-y-6">
|
|
||||||
<div className="flex flex-col items-center space-y-4">
|
|
||||||
<div className="h-32 flex items-center justify-center w-full relative">
|
|
||||||
<img src={KofiLogo} className="w-72 absolute pointer-events-none" alt="Ko-fi"/>
|
|
||||||
</div>
|
|
||||||
<h4 className="font-semibold text-foreground">Support via Ko-fi</h4>
|
|
||||||
<p className="text-sm text-muted-foreground text-center px-4">
|
|
||||||
Enjoying the project? You can support ongoing development by buying me a coffee.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button className="h-10 w-full text-sm font-semibold text-white gap-2 group bg-[#72a4f2] hover:bg-[#5f8cd6]" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
|
||||||
<img src={KofiSvg} className="w-5 h-5 shrink-0" alt="" aria-hidden="true"/>
|
|
||||||
Support me on Ko-fi
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div className="flex-1 p-6 flex flex-col items-center justify-between space-y-6">
|
|
||||||
<div className="flex flex-col items-center space-y-4 w-full">
|
|
||||||
<div className="h-32 flex items-center justify-center">
|
|
||||||
<div className="p-2 bg-white rounded-xl shadow-sm border">
|
|
||||||
<img src={UsdtBarcode} className="w-24 h-24 object-contain" alt="USDT Barcode"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h4 className="font-semibold text-foreground">USDT (TRC20)</h4>
|
|
||||||
<p className="text-sm text-muted-foreground text-center px-4">
|
|
||||||
Crypto donations are also accepted. Scan the QR code or copy the address.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 bg-muted/50 pl-3 pr-1.5 py-1.5 rounded-lg border w-full justify-between h-10">
|
|
||||||
<code className="text-xs font-mono text-muted-foreground truncate" title="THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs">
|
|
||||||
THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs
|
|
||||||
</code>
|
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 hover:bg-background" onClick={() => {
|
|
||||||
navigator.clipboard.writeText("THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs");
|
|
||||||
setCopiedUsdt(true);
|
|
||||||
setTimeout(() => setCopiedUsdt(false), 500);
|
|
||||||
}}>
|
|
||||||
{copiedUsdt ? <CircleCheck className="h-3.5 w-3.5 text-green-500"/> : <Copy className="h-3.5 w-3.5"/>}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>);
|
</div>);
|
||||||
}
|
}
|
||||||
@@ -604,14 +604,22 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
|||||||
|
|
||||||
{!searchMode && (<>
|
{!searchMode && (<>
|
||||||
{showRegionSelector && (<Select value={region} onValueChange={onRegionChange}>
|
{showRegionSelector && (<Select value={region} onValueChange={onRegionChange}>
|
||||||
<SelectTrigger className="w-[70px] shrink-0">
|
<SelectTrigger className="w-22.5 shrink-0">
|
||||||
<SelectValue placeholder="Region"/>
|
<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>
|
</SelectTrigger>
|
||||||
<SelectContent className="max-h-[300px]">
|
<SelectContent className="max-h-[300px]">
|
||||||
{REGIONS.map((r) => (<SelectItem key={r} value={r} textValue={r}>
|
{REGIONS.map((r) => (<SelectItem key={r} value={r} textValue={r}>
|
||||||
{r}{" "}
|
<span className="flex items-center gap-1.5">
|
||||||
<span className="text-muted-foreground">
|
<img src={`/assets/flags/${r.toLowerCase()}.svg`} alt="" className="h-3 w-4 rounded-[1px] object-cover shrink-0"/>
|
||||||
({getRegionName(r)})
|
{r}{" "}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
({getRegionName(r)})
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>))}
|
</SelectItem>))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import { InputWithContext } from "@/components/ui/input-with-context";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||||
import { FolderOpen, Save, RotateCcw, Info, ArrowRight, MonitorCog, FolderCog, Router, FolderLock, Plus, Trash2, ExternalLink } from "lucide-react";
|
import { FolderOpen, Save, RotateCcw, Info, ArrowRight, MonitorCog, FolderCog, Router, FolderLock, Plus, Trash2, ExternalLink, PlugZap, Download, Tags } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, getFontOptions, parseGoogleFontUrl, loadGoogleFontUrl, loadCustomFonts, saveCustomFonts, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type CustomFontFamily, type FolderPreset, type FilenamePreset, type ExistingFileCheckMode, } from "@/lib/settings";
|
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, getFontOptions, parseGoogleFontUrl, loadGoogleFontUrl, loadCustomFonts, saveCustomFonts, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, sanitizeAutoOrder, type Settings as SettingsType, type FontFamily, type CustomFontFamily, type FolderPreset, type FilenamePreset, type ExistingFileCheckMode, } from "@/lib/settings";
|
||||||
import { themes, applyTheme } from "@/lib/themes";
|
import { themes, applyTheme } from "@/lib/themes";
|
||||||
import { SelectFolder, OpenConfigFolder, CheckCustomTidalAPI } from "../../wailsjs/go/main/App";
|
import { SelectFolder, OpenConfigFolder, CheckCustomTidalAPI, CheckCustomQobuzAPI } from "../../wailsjs/go/main/App";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { openExternal } from "@/lib/utils";
|
import { openExternal } from "@/lib/utils";
|
||||||
import { ApiStatusTab } from "./ApiStatusTab";
|
import { ApiStatusTab } from "./ApiStatusTab";
|
||||||
@@ -28,11 +28,15 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
const [showAddFontDialog, setShowAddFontDialog] = useState(false);
|
const [showAddFontDialog, setShowAddFontDialog] = useState(false);
|
||||||
const [showCustomTidalApiDialog, setShowCustomTidalApiDialog] = useState(false);
|
const [showCustomTidalApiDialog, setShowCustomTidalApiDialog] = useState(false);
|
||||||
|
const [showCustomQobuzApiDialog, setShowCustomQobuzApiDialog] = useState(false);
|
||||||
const [addFontUrl, setAddFontUrl] = useState("");
|
const [addFontUrl, setAddFontUrl] = useState("");
|
||||||
const [customTidalApiStatus, setCustomTidalApiStatus] = useState<CustomTidalApiStatus>("idle");
|
const [customTidalApiStatus, setCustomTidalApiStatus] = useState<CustomTidalApiStatus>("idle");
|
||||||
|
const [customQobuzApiStatus, setCustomQobuzApiStatus] = useState<CustomTidalApiStatus>("idle");
|
||||||
const parsedAddFont = parseGoogleFontUrl(addFontUrl);
|
const parsedAddFont = parseGoogleFontUrl(addFontUrl);
|
||||||
const fontOptions = getFontOptions(tempSettings.customFonts);
|
const fontOptions = getFontOptions(tempSettings.customFonts);
|
||||||
const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
|
const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
|
||||||
|
const effectiveDownloader = tempSettings.downloader;
|
||||||
|
const effectiveAutoOrder = sanitizeAutoOrder(tempSettings.autoOrder);
|
||||||
const resetToSaved = useCallback(() => {
|
const resetToSaved = useCallback(() => {
|
||||||
const freshSavedSettings = getSettings();
|
const freshSavedSettings = getSettings();
|
||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
@@ -96,7 +100,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
}, []);
|
}, []);
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
await saveSettings(tempSettings);
|
await saveSettings(tempSettings);
|
||||||
setSavedSettings(tempSettings);
|
const persistedSettings = getSettings();
|
||||||
|
setSavedSettings(persistedSettings);
|
||||||
|
setTempSettings(persistedSettings);
|
||||||
toast.success("Settings saved");
|
toast.success("Settings saved");
|
||||||
onUnsavedChangesChange?.(false);
|
onUnsavedChangesChange?.(false);
|
||||||
};
|
};
|
||||||
@@ -173,6 +179,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
const handleQobuzQualityChange = (value: "6" | "7" | "27") => {
|
const handleQobuzQualityChange = (value: "6" | "7" | "27") => {
|
||||||
setTempSettings((prev) => ({ ...prev, qobuzQuality: value }));
|
setTempSettings((prev) => ({ ...prev, qobuzQuality: value }));
|
||||||
};
|
};
|
||||||
|
const handleAmazonQualityChange = (value: "16" | "24") => {
|
||||||
|
setTempSettings((prev) => ({ ...prev, amazonQuality: value }));
|
||||||
|
};
|
||||||
const handleAutoQualityChange = async (value: "16" | "24") => {
|
const handleAutoQualityChange = async (value: "16" | "24") => {
|
||||||
setTempSettings((prev) => ({ ...prev, autoQuality: value }));
|
setTempSettings((prev) => ({ ...prev, autoQuality: value }));
|
||||||
};
|
};
|
||||||
@@ -184,13 +193,26 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
customTidalApi: normalizedValue,
|
customTidalApi: normalizedValue,
|
||||||
};
|
};
|
||||||
await saveSettings(nextSavedSettings);
|
await saveSettings(nextSavedSettings);
|
||||||
setSavedSettings((prev) => ({
|
const nextSavedState = getSettings();
|
||||||
...prev,
|
setSavedSettings(nextSavedState);
|
||||||
customTidalApi: normalizedValue,
|
|
||||||
}));
|
|
||||||
setTempSettings((prev) => ({
|
setTempSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
customTidalApi: normalizedValue,
|
customTidalApi: nextSavedState.customTidalApi,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
const persistCustomQobuzApi = useCallback(async (nextValue: string) => {
|
||||||
|
const normalizedValue = nextValue.trim().replace(/\/+$/g, "");
|
||||||
|
const persistedSettings = getSettings();
|
||||||
|
const nextSavedSettings: SettingsType = {
|
||||||
|
...persistedSettings,
|
||||||
|
customQobuzApi: normalizedValue,
|
||||||
|
};
|
||||||
|
await saveSettings(nextSavedSettings);
|
||||||
|
const nextSavedState = getSettings();
|
||||||
|
setSavedSettings(nextSavedState);
|
||||||
|
setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
customQobuzApi: nextSavedState.customQobuzApi,
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
const handleCheckCustomTidalApi = async () => {
|
const handleCheckCustomTidalApi = async () => {
|
||||||
@@ -216,7 +238,30 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
toast.error(`Failed to check HiFi API instance: ${error}`);
|
toast.error(`Failed to check HiFi API instance: ${error}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const [activeTab, setActiveTab] = useState<"general" | "files" | "api">("general");
|
const handleCheckCustomQobuzApi = async () => {
|
||||||
|
const normalizedCustomQobuzApi = (tempSettings.customQobuzApi || "").trim().replace(/\/+$/g, "");
|
||||||
|
if (!normalizedCustomQobuzApi.startsWith("https://")) {
|
||||||
|
toast.error("Enter a valid HTTPS Qobuz-DL instance URL");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCustomQobuzApiStatus("checking");
|
||||||
|
try {
|
||||||
|
const isOnline = await CheckCustomQobuzAPI(normalizedCustomQobuzApi);
|
||||||
|
setCustomQobuzApiStatus(isOnline ? "online" : "offline");
|
||||||
|
if (isOnline) {
|
||||||
|
toast.success("Qobuz-DL instance is online");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.error("Qobuz-DL instance is offline");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Failed to check custom Qobuz API:", error);
|
||||||
|
setCustomQobuzApiStatus("offline");
|
||||||
|
toast.error(`Failed to check Qobuz-DL instance: ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const [activeTab, setActiveTab] = useState<"general" | "download" | "files" | "metadata" | "status">("general");
|
||||||
return (<div className="space-y-4 h-full flex flex-col">
|
return (<div className="space-y-4 h-full flex flex-col">
|
||||||
<div className="flex items-center justify-between shrink-0">
|
<div className="flex items-center justify-between shrink-0">
|
||||||
<h1 className="text-2xl font-bold">Settings</h1>
|
<h1 className="text-2xl font-bold">Settings</h1>
|
||||||
@@ -248,33 +293,27 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
<MonitorCog className="h-4 w-4"/>
|
<MonitorCog className="h-4 w-4"/>
|
||||||
General
|
General
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant={activeTab === "download" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("download")} className="rounded-b-none gap-2">
|
||||||
|
<Download className="h-4 w-4"/>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
<Button variant={activeTab === "files" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("files")} className="rounded-b-none gap-2">
|
<Button variant={activeTab === "files" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("files")} className="rounded-b-none gap-2">
|
||||||
<FolderCog className="h-4 w-4"/>
|
<FolderCog className="h-4 w-4"/>
|
||||||
File Management
|
Files
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant={activeTab === "api" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("api")} className="rounded-b-none gap-2">
|
<Button variant={activeTab === "metadata" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("metadata")} className="rounded-b-none gap-2">
|
||||||
|
<Tags className="h-4 w-4"/>
|
||||||
|
Metadata
|
||||||
|
</Button>
|
||||||
|
<Button variant={activeTab === "status" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("status")} className="rounded-b-none gap-2">
|
||||||
<Router className="h-4 w-4"/>
|
<Router className="h-4 w-4"/>
|
||||||
Status
|
Status
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto pt-4">
|
<div className="flex-1 overflow-y-auto pt-4">
|
||||||
{activeTab === "general" && (<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
{activeTab === "general" && (<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="download-path">Download Path</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<InputWithContext id="download-path" value={tempSettings.downloadPath} onChange={(e) => setTempSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
downloadPath: e.target.value,
|
|
||||||
}))} placeholder="C:\Users\YourUsername\Music"/>
|
|
||||||
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
|
|
||||||
<FolderOpen className="h-4 w-4"/>
|
|
||||||
Browse
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="theme-mode">Mode</Label>
|
<Label htmlFor="theme-mode">Mode</Label>
|
||||||
<Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}>
|
<Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}>
|
||||||
@@ -309,7 +348,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="font">Font</Label>
|
<Label htmlFor="font">Font</Label>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@@ -357,8 +398,10 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
<div className="space-y-4">
|
{activeTab === "download" && (<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-8 items-start">
|
||||||
|
<div className="space-y-4 lg:pr-8 lg:border-r">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="link-resolver">Link Resolver</Label>
|
<Label htmlFor="link-resolver">Link Resolver</Label>
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
@@ -384,23 +427,37 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 pt-2">
|
||||||
<Switch id="allow-link-resolver-fallback" checked={tempSettings.allowResolverFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
<Switch id="allow-link-resolver-fallback" checked={tempSettings.allowResolverFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
allowResolverFallback: checked,
|
allowResolverFallback: checked,
|
||||||
}))}/>
|
}))}/>
|
||||||
<Label htmlFor="allow-link-resolver-fallback" className="text-sm font-normal cursor-pointer">
|
<Label htmlFor="allow-link-resolver-fallback" className="text-sm font-normal cursor-pointer">
|
||||||
Allow Fallback
|
Allow Resolver Fallback
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-base font-semibold">Community</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
<p className="text-xs whitespace-nowrap">1 track / 30s</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="downloader">Source</Label>
|
<Label htmlFor="downloader">Source</Label>
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<Select value={tempSettings.downloader} onValueChange={(value: SettingsType["downloader"]) => setTempSettings((prev) => ({
|
<Select value={effectiveDownloader} onValueChange={(value: SettingsType["downloader"]) => setTempSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
downloader: value,
|
downloader: value,
|
||||||
}))}>
|
}))}>
|
||||||
@@ -427,97 +484,100 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
Amazon Music
|
Amazon Music
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{tempSettings.downloader === "auto" && (<>
|
{effectiveDownloader === "auto" && (<>
|
||||||
<Select value={tempSettings.autoOrder || "tidal-qobuz-amazon"} onValueChange={(value: string) => setTempSettings((prev) => ({
|
<Select value={effectiveAutoOrder} onValueChange={(value: string) => setTempSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
autoOrder: value,
|
autoOrder: value,
|
||||||
}))}>
|
}))}>
|
||||||
<SelectTrigger className="h-9 w-fit min-w-35">
|
<SelectTrigger className="h-9 w-auto">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent className="w-fit min-w-max">
|
||||||
|
|
||||||
<SelectItem value="tidal-qobuz-amazon">
|
<SelectItem value="tidal-qobuz-amazon">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<TidalIcon className="fill-current"/>
|
<TidalIcon className="fill-current"/>
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
<QobuzIcon className="fill-current"/>
|
<QobuzIcon className="fill-current"/>
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
<AmazonIcon className="fill-current"/>
|
<AmazonIcon className="fill-current"/>
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="tidal-amazon-qobuz">
|
<SelectItem value="tidal-amazon-qobuz">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<TidalIcon className="fill-current"/>
|
<TidalIcon className="fill-current"/>
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
<AmazonIcon className="fill-current"/>
|
<AmazonIcon className="fill-current"/>
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
<QobuzIcon className="fill-current"/>
|
<QobuzIcon className="fill-current"/>
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="qobuz-tidal-amazon">
|
<SelectItem value="qobuz-tidal-amazon">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<QobuzIcon className="fill-current"/>
|
<QobuzIcon className="fill-current"/>
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
<TidalIcon className="fill-current"/>
|
<TidalIcon className="fill-current"/>
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
<AmazonIcon className="fill-current"/>
|
<AmazonIcon className="fill-current"/>
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="qobuz-amazon-tidal">
|
<SelectItem value="qobuz-amazon-tidal">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<QobuzIcon className="fill-current"/>
|
<QobuzIcon className="fill-current"/>
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
<AmazonIcon className="fill-current"/>
|
<AmazonIcon className="fill-current"/>
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
<TidalIcon className="fill-current"/>
|
<TidalIcon className="fill-current"/>
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="amazon-tidal-qobuz">
|
<SelectItem value="amazon-tidal-qobuz">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<AmazonIcon className="fill-current"/>
|
<AmazonIcon className="fill-current"/>
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
<TidalIcon className="fill-current"/>
|
<TidalIcon className="fill-current"/>
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
<QobuzIcon className="fill-current"/>
|
<QobuzIcon className="fill-current"/>
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="amazon-qobuz-tidal">
|
<SelectItem value="amazon-qobuz-tidal">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<AmazonIcon className="fill-current"/>
|
<AmazonIcon className="fill-current"/>
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
<QobuzIcon className="fill-current"/>
|
<QobuzIcon className="fill-current"/>
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
<TidalIcon className="fill-current"/>
|
<TidalIcon className="fill-current"/>
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
<SelectItem value="tidal-qobuz">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
<SelectItem value="tidal-qobuz">
|
<TidalIcon className="fill-current"/>
|
||||||
<span className="flex items-center gap-1.5">
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
<TidalIcon className="fill-current"/>
|
<QobuzIcon className="fill-current"/>
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
</span>
|
||||||
<QobuzIcon className="fill-current"/>
|
</SelectItem>
|
||||||
</span>
|
<SelectItem value="tidal-amazon">
|
||||||
</SelectItem>
|
<span className="flex items-center gap-1.5">
|
||||||
<SelectItem value="tidal-amazon">
|
<TidalIcon className="fill-current"/>
|
||||||
<span className="flex items-center gap-1.5">
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
<TidalIcon className="fill-current"/>
|
<AmazonIcon className="fill-current"/>
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
</span>
|
||||||
<AmazonIcon className="fill-current"/>
|
</SelectItem>
|
||||||
</span>
|
<SelectItem value="qobuz-tidal">
|
||||||
</SelectItem>
|
<span className="flex items-center gap-1.5">
|
||||||
<SelectItem value="qobuz-tidal">
|
<QobuzIcon className="fill-current"/>
|
||||||
<span className="flex items-center gap-1.5">
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
<QobuzIcon className="fill-current"/>
|
<TidalIcon className="fill-current"/>
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
</span>
|
||||||
<TidalIcon className="fill-current"/>
|
</SelectItem>
|
||||||
</span>
|
<SelectItem value="amazon-tidal">
|
||||||
</SelectItem>
|
<span className="flex items-center gap-1.5">
|
||||||
|
<AmazonIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<TidalIcon className="fill-current"/>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
<SelectItem value="qobuz-amazon">
|
<SelectItem value="qobuz-amazon">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<QobuzIcon className="fill-current"/>
|
<QobuzIcon className="fill-current"/>
|
||||||
@@ -525,13 +585,6 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
<AmazonIcon className="fill-current"/>
|
<AmazonIcon className="fill-current"/>
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="amazon-tidal">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<AmazonIcon className="fill-current"/>
|
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
|
||||||
<TidalIcon className="fill-current"/>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="amazon-qobuz">
|
<SelectItem value="amazon-qobuz">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<AmazonIcon className="fill-current"/>
|
<AmazonIcon className="fill-current"/>
|
||||||
@@ -553,19 +606,17 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
</Select>
|
</Select>
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
{tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
|
{effectiveDownloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
|
||||||
<SelectTrigger className="h-9 w-fit">
|
<SelectTrigger className="h-9 w-fit">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="LOSSLESS">16-bit/44.1kHz</SelectItem>
|
<SelectItem value="LOSSLESS">16-bit/44.1kHz</SelectItem>
|
||||||
<SelectItem value="HI_RES_LOSSLESS">
|
<SelectItem value="HI_RES_LOSSLESS">24-bit/48kHz</SelectItem>
|
||||||
24-bit/48kHz
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>)}
|
</Select>)}
|
||||||
|
|
||||||
{tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={handleQobuzQualityChange}>
|
{effectiveDownloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={handleQobuzQualityChange}>
|
||||||
<SelectTrigger className="h-9 w-fit">
|
<SelectTrigger className="h-9 w-fit">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -575,17 +626,24 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>)}
|
</Select>)}
|
||||||
|
|
||||||
{tempSettings.downloader === "amazon" && (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default">
|
{effectiveDownloader === "amazon" && (<Select value={tempSettings.amazonQuality} onValueChange={handleAmazonQualityChange}>
|
||||||
16-bit - 24-bit/44.1kHz - 192kHz
|
<SelectTrigger className="h-9 w-fit">
|
||||||
</div>)}
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="16">16-bit/44.1kHz</SelectItem>
|
||||||
|
<SelectItem value="24">24-bit/48kHz - 192kHz</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{((tempSettings.downloader === "tidal" &&
|
{((effectiveDownloader === "tidal" &&
|
||||||
tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
|
tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
|
||||||
(tempSettings.downloader === "qobuz" &&
|
(effectiveDownloader === "qobuz" &&
|
||||||
tempSettings.qobuzQuality === "27") ||
|
tempSettings.qobuzQuality === "27") ||
|
||||||
(tempSettings.downloader === "auto" &&
|
(effectiveDownloader === "amazon" &&
|
||||||
|
tempSettings.amazonQuality === "24") ||
|
||||||
|
(effectiveDownloader === "auto" &&
|
||||||
tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pt-2">
|
tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pt-2">
|
||||||
<Switch id="allow-fallback" checked={tempSettings.allowFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
<Switch id="allow-fallback" checked={tempSettings.allowFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -595,66 +653,58 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
Allow Quality Fallback (16-bit)
|
Allow Quality Fallback (16-bit)
|
||||||
</Label>
|
</Label>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{(tempSettings.downloader === "auto" || tempSettings.downloader === "tidal") && (<div className="space-y-2 pt-2">
|
<div className="space-y-4">
|
||||||
<Label>Custom Instance</Label>
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
<Label className="text-base font-semibold">Custom</Label>
|
||||||
<Button type="button" variant="outline" onClick={() => setShowCustomTidalApiDialog(true)} className="gap-2">
|
|
||||||
<TidalIcon />
|
|
||||||
Configure
|
|
||||||
</Button>
|
|
||||||
{tempSettings.customTidalApi && (<span className="max-w-[260px] truncate text-xs text-muted-foreground" title={tempSettings.customTidalApi}>
|
|
||||||
{tempSettings.customTidalApi}
|
|
||||||
</span>)}
|
|
||||||
</div>
|
|
||||||
</div>)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t pt-2"/>
|
<div className="space-y-2">
|
||||||
|
<Label>Tidal</Label>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setShowCustomTidalApiDialog(true)} className="gap-2">
|
||||||
|
<TidalIcon />
|
||||||
|
{tempSettings.customTidalApi ? "Change Instance" : "Add Instance"}
|
||||||
|
</Button>
|
||||||
|
{tempSettings.customTidalApi && (<span className="max-w-[260px] truncate text-xs text-muted-foreground" title={tempSettings.customTidalApi}>
|
||||||
|
{tempSettings.customTidalApi}
|
||||||
|
</span>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-3">
|
<Label>Qobuz</Label>
|
||||||
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
...prev,
|
<Button type="button" variant="outline" onClick={() => setShowCustomQobuzApiDialog(true)} className="gap-2">
|
||||||
embedMaxQualityCover: checked,
|
<QobuzIcon />
|
||||||
}))}/>
|
{tempSettings.customQobuzApi ? "Change Instance" : "Add Instance"}
|
||||||
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm font-normal">
|
</Button>
|
||||||
Embed Max Quality Cover
|
{tempSettings.customQobuzApi && (<span className="max-w-[260px] truncate text-xs text-muted-foreground" title={tempSettings.customQobuzApi}>
|
||||||
</Label>
|
{tempSettings.customQobuzApi}
|
||||||
</div>
|
</span>)}
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Switch id="embed-genre" checked={tempSettings.embedGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
embedGenre: checked,
|
|
||||||
}))}/>
|
|
||||||
<Label htmlFor="embed-genre" className="cursor-pointer text-sm font-normal">
|
|
||||||
Embed Genre
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
{tempSettings.embedGenre && (<div className="flex items-center gap-3">
|
|
||||||
<Switch id="use-single-genre" checked={tempSettings.useSingleGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
useSingleGenre: checked,
|
|
||||||
}))}/>
|
|
||||||
<Label htmlFor="use-single-genre" className="text-sm cursor-pointer font-normal">
|
|
||||||
Use Single Genre
|
|
||||||
</Label>
|
|
||||||
</div>)}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
embedLyrics: checked,
|
|
||||||
}))}/>
|
|
||||||
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm font-normal">
|
|
||||||
Embed Lyrics
|
|
||||||
</Label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
{activeTab === "files" && (<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
{activeTab === "files" && (<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-8 items-start">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 lg:pr-8 lg:border-r">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="download-path">Download Path</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<InputWithContext id="download-path" value={tempSettings.downloadPath} onChange={(e) => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
downloadPath: e.target.value,
|
||||||
|
}))} placeholder="C:\Users\YourUsername\Music"/>
|
||||||
|
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
|
||||||
|
<FolderOpen className="h-4 w-4"/>
|
||||||
|
Browse
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="text-sm">Folder Structure</Label>
|
<Label className="text-sm">Folder Structure</Label>
|
||||||
@@ -742,31 +792,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
Create M3U8 Playlist File
|
Create M3U8 Playlist File
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Switch id="use-first-artist-only" checked={tempSettings.useFirstArtistOnly} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
useFirstArtistOnly: checked,
|
|
||||||
}))}/>
|
|
||||||
<Label htmlFor="use-first-artist-only" className="text-sm cursor-pointer font-normal">
|
|
||||||
Use First Artist Only
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Switch id="redownload-with-suffix" checked={tempSettings.redownloadWithSuffix} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
redownloadWithSuffix: checked,
|
|
||||||
}))}/>
|
|
||||||
<Label htmlFor="redownload-with-suffix" className="text-sm cursor-pointer font-normal">
|
|
||||||
Redownload With Suffix
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 lg:pl-0">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="existing-file-check-mode">Existing File Check</Label>
|
<Label htmlFor="existing-file-check-mode">Existing File Check</Label>
|
||||||
<Select value={tempSettings.existingFileCheckMode} onValueChange={(value: ExistingFileCheckMode) => setTempSettings((prev) => ({
|
<Select value={tempSettings.existingFileCheckMode} onValueChange={(value: ExistingFileCheckMode) => setTempSettings((prev) => ({
|
||||||
@@ -784,22 +812,22 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="text-sm">Filename Format</Label>
|
<Label className="text-sm">Filename Format</Label>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
<p className="text-xs whitespace-nowrap">
|
<p className="text-xs whitespace-nowrap">
|
||||||
Variables:{" "}
|
Variables:{" "}
|
||||||
{TEMPLATE_VARIABLES.map((v) => v.key).join(", ")}
|
{TEMPLATE_VARIABLES.map((v) => v.key).join(", ")}
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select value={tempSettings.filenamePreset} onValueChange={(value: FilenamePreset) => {
|
<Select value={tempSettings.filenamePreset} onValueChange={(value: FilenamePreset) => {
|
||||||
const preset = FILENAME_PRESETS[value];
|
const preset = FILENAME_PRESETS[value];
|
||||||
setTempSettings((prev) => ({
|
setTempSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -809,42 +837,24 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
: preset.template,
|
: preset.template,
|
||||||
}));
|
}));
|
||||||
}}>
|
}}>
|
||||||
<SelectTrigger className="h-9 w-fit">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>
|
|
||||||
{label}
|
|
||||||
</SelectItem>))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{tempSettings.filenamePreset === "custom" && (<InputWithContext value={tempSettings.filenameTemplate} onChange={(e) => setTempSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
filenameTemplate: e.target.value,
|
|
||||||
}))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 pt-2">
|
|
||||||
<Label className="text-sm">Separator</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Select value={tempSettings.separator} onValueChange={(value: "comma" | "semicolon") => setTempSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
separator: value,
|
|
||||||
}))}>
|
|
||||||
<SelectTrigger className="h-9 w-fit">
|
<SelectTrigger className="h-9 w-fit">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="comma">Comma (,)</SelectItem>
|
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>
|
||||||
<SelectItem value="semicolon">Semicolon (;)</SelectItem>
|
{label}
|
||||||
|
</SelectItem>))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
{tempSettings.filenamePreset === "custom" && (<InputWithContext value={tempSettings.filenameTemplate} onChange={(e) => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
filenameTemplate: e.target.value,
|
||||||
|
}))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
|
||||||
|
Preview:{" "}
|
||||||
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
|
<span className="font-mono">
|
||||||
Preview:{" "}
|
{tempSettings.filenameTemplate
|
||||||
<span className="font-mono">
|
|
||||||
{tempSettings.filenameTemplate
|
|
||||||
.replace(/\{artist\}/g, tempSettings.separator === "comma" ? "Kendrick Lamar, SZA" : "Kendrick Lamar; SZA")
|
.replace(/\{artist\}/g, tempSettings.separator === "comma" ? "Kendrick Lamar, SZA" : "Kendrick Lamar; SZA")
|
||||||
.replace(/\{album_artist\}/g, "Kendrick Lamar")
|
.replace(/\{album_artist\}/g, "Kendrick Lamar")
|
||||||
.replace(/\{album\}/g, "Black Panther")
|
.replace(/\{album\}/g, "Black Panther")
|
||||||
@@ -858,10 +868,92 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
</span>
|
</span>
|
||||||
</p>)}
|
</p>)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm">Separator</Label>
|
||||||
|
<Select value={tempSettings.separator} onValueChange={(value: "comma" | "semicolon") => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
separator: value,
|
||||||
|
}))}>
|
||||||
|
<SelectTrigger className="h-9 w-fit">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="comma">Comma (,)</SelectItem>
|
||||||
|
<SelectItem value="semicolon">Semicolon (;)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch id="redownload-with-suffix" checked={tempSettings.redownloadWithSuffix} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
redownloadWithSuffix: checked,
|
||||||
|
}))}/>
|
||||||
|
<Label htmlFor="redownload-with-suffix" className="text-sm cursor-pointer font-normal">
|
||||||
|
Redownload With Suffix
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
{activeTab === "api" && (<ApiStatusTab />)}
|
{activeTab === "metadata" && (<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
embedLyrics: checked,
|
||||||
|
}))}/>
|
||||||
|
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm font-normal">
|
||||||
|
Embed Lyrics
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
embedMaxQualityCover: checked,
|
||||||
|
}))}/>
|
||||||
|
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm font-normal">
|
||||||
|
Embed Max Quality Cover
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch id="embed-genre" checked={tempSettings.embedGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
embedGenre: checked,
|
||||||
|
}))}/>
|
||||||
|
<Label htmlFor="embed-genre" className="cursor-pointer text-sm font-normal">
|
||||||
|
Embed Genre
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tempSettings.embedGenre && (<div className="flex items-center gap-3">
|
||||||
|
<Switch id="use-single-genre" checked={tempSettings.useSingleGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
useSingleGenre: checked,
|
||||||
|
}))}/>
|
||||||
|
<Label htmlFor="use-single-genre" className="text-sm cursor-pointer font-normal">
|
||||||
|
Use Single Genre
|
||||||
|
</Label>
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch id="use-first-artist-only" checked={tempSettings.useFirstArtistOnly} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
useFirstArtistOnly: checked,
|
||||||
|
}))}/>
|
||||||
|
<Label htmlFor="use-first-artist-only" className="text-sm cursor-pointer font-normal">
|
||||||
|
Use First Artist Only
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
{activeTab === "status" && (<ApiStatusTab />)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={showAddFontDialog} onOpenChange={(open) => open ? setShowAddFontDialog(true) : closeAddFontDialog()}>
|
<Dialog open={showAddFontDialog} onOpenChange={(open) => open ? setShowAddFontDialog(true) : closeAddFontDialog()}>
|
||||||
@@ -915,9 +1007,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
<DialogContent className="sm:max-w-md [&>button]:hidden">
|
<DialogContent className="sm:max-w-md [&>button]:hidden">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<DialogTitle>Custom Instance</DialogTitle>
|
<DialogTitle>Tidal Source</DialogTitle>
|
||||||
<button type="button" onClick={() => openExternal("https://github.com/binimum/hifi-api")} className="inline-flex cursor-pointer items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline">
|
<button type="button" onClick={() => openExternal("https://github.com/binimum/hifi-api")} className="inline-flex cursor-pointer items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline">
|
||||||
How to create your own instance
|
How do I create one?
|
||||||
<ExternalLink className="h-3 w-3"/>
|
<ExternalLink className="h-3 w-3"/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -932,8 +1024,8 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
setCustomTidalApiStatus("idle");
|
setCustomTidalApiStatus("idle");
|
||||||
void persistCustomTidalApi(nextValue);
|
void persistCustomTidalApi(nextValue);
|
||||||
}} placeholder="https://your-hifi-api.example"/>
|
}} placeholder="https://your-hifi-api.example"/>
|
||||||
<Button type="button" variant="outline" onClick={() => void handleCheckCustomTidalApi()} disabled={!((tempSettings.customTidalApi || "").trim().startsWith("https://")) || customTidalApiStatus === "checking"}>
|
<Button type="button" variant="outline" className="gap-2" onClick={() => void handleCheckCustomTidalApi()} disabled={!((tempSettings.customTidalApi || "").trim().startsWith("https://")) || customTidalApiStatus === "checking"}>
|
||||||
{customTidalApiStatus === "checking" ? "Checking..." : "Check"}
|
{customTidalApiStatus === "checking" ? "Checking..." : <><PlugZap className="h-4 w-4"/>Check</>}
|
||||||
</Button>
|
</Button>
|
||||||
{tempSettings.customTidalApi && (<Button type="button" variant="outline" size="icon" onClick={() => {
|
{tempSettings.customTidalApi && (<Button type="button" variant="outline" size="icon" onClick={() => {
|
||||||
setCustomTidalApiStatus("idle");
|
setCustomTidalApiStatus("idle");
|
||||||
@@ -963,6 +1055,58 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={showCustomQobuzApiDialog} onOpenChange={setShowCustomQobuzApiDialog}>
|
||||||
|
<DialogContent className="sm:max-w-md [&>button]:hidden">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<DialogTitle>Qobuz Source</DialogTitle>
|
||||||
|
<button type="button" onClick={() => openExternal("https://github.com/QobuzDL/Qobuz-DL")} className="inline-flex cursor-pointer items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline">
|
||||||
|
How do I create one?
|
||||||
|
<ExternalLink className="h-3 w-3"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<DialogDescription />
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="custom-qobuz-api">Instance URL</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input id="custom-qobuz-api" type="url" value={tempSettings.customQobuzApi || ""} onChange={(e) => {
|
||||||
|
const nextValue = e.target.value.replace(/\/+$/g, "");
|
||||||
|
setCustomQobuzApiStatus("idle");
|
||||||
|
void persistCustomQobuzApi(nextValue);
|
||||||
|
}} placeholder="https://your-qobuz-dl.example"/>
|
||||||
|
<Button type="button" variant="outline" className="gap-2" onClick={() => void handleCheckCustomQobuzApi()} disabled={!((tempSettings.customQobuzApi || "").trim().startsWith("https://")) || customQobuzApiStatus === "checking"}>
|
||||||
|
{customQobuzApiStatus === "checking" ? "Checking..." : <><PlugZap className="h-4 w-4"/>Check</>}
|
||||||
|
</Button>
|
||||||
|
{tempSettings.customQobuzApi && (<Button type="button" variant="outline" size="icon" onClick={() => {
|
||||||
|
setCustomQobuzApiStatus("idle");
|
||||||
|
void persistCustomQobuzApi("");
|
||||||
|
}}>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive"/>
|
||||||
|
</Button>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{customQobuzApiStatus !== "idle" && (<p className={`text-xs ${customQobuzApiStatus === "online"
|
||||||
|
? "text-green-600 dark:text-green-400"
|
||||||
|
: customQobuzApiStatus === "offline"
|
||||||
|
? "text-destructive"
|
||||||
|
: "text-muted-foreground"}`}>
|
||||||
|
{customQobuzApiStatus === "online"
|
||||||
|
? "Custom Qobuz-DL instance is online."
|
||||||
|
: customQobuzApiStatus === "offline"
|
||||||
|
? "Custom Qobuz-DL instance is offline or returned no download URL."
|
||||||
|
: "Checking custom Qobuz-DL instance..."}
|
||||||
|
</p>)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowCustomQobuzApiDialog(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
||||||
<DialogContent className="max-w-md [&>button]:hidden">
|
<DialogContent className="max-w-md [&>button]:hidden">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -6,18 +6,19 @@ import { ActivityIcon, type ActivityIconHandle } from "@/components/ui/activity"
|
|||||||
import { TerminalIcon } from "@/components/ui/terminal";
|
import { TerminalIcon } from "@/components/ui/terminal";
|
||||||
import { FileMusicIcon, type FileMusicIconHandle } from "@/components/ui/file-music";
|
import { FileMusicIcon, type FileMusicIconHandle } from "@/components/ui/file-music";
|
||||||
import { FilePenIcon, type FilePenIconHandle } from "@/components/ui/file-pen";
|
import { FilePenIcon, type FilePenIconHandle } from "@/components/ui/file-pen";
|
||||||
|
import { FileTextIcon, type FileTextIconHandle } from "@/components/ui/file-text";
|
||||||
|
import { BugReportIcon } from "@/components/ui/bug-report-icon";
|
||||||
import { CoffeeIcon } from "@/components/ui/coffee";
|
import { CoffeeIcon } from "@/components/ui/coffee";
|
||||||
import { BadgeAlertIcon } from "@/components/ui/badge-alert";
|
|
||||||
import { GithubIcon } from "@/components/ui/github";
|
|
||||||
import { BlocksIcon } from "@/components/ui/blocks-icon";
|
import { BlocksIcon } from "@/components/ui/blocks-icon";
|
||||||
import { AudioLinesIcon, type AudioLinesIconHandle } from "@/components/ui/audio-lines";
|
import { AudioLinesIcon, type AudioLinesIconHandle } from "@/components/ui/audio-lines";
|
||||||
|
import { ToolCaseIcon } from "@/components/ui/tool-case";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { openExternal } from "@/lib/utils";
|
import { openExternal } from "@/lib/utils";
|
||||||
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "about" | "history";
|
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "lyrics-manager" | "projects" | "support" | "history";
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
currentPage: PageType;
|
currentPage: PageType;
|
||||||
onPageChange: (page: PageType) => void;
|
onPageChange: (page: PageType) => void;
|
||||||
@@ -33,6 +34,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
const resamplerIconRef = useRef<AudioLinesIconHandle>(null);
|
const resamplerIconRef = useRef<AudioLinesIconHandle>(null);
|
||||||
const converterIconRef = useRef<FileMusicIconHandle>(null);
|
const converterIconRef = useRef<FileMusicIconHandle>(null);
|
||||||
const fileManagerIconRef = useRef<FilePenIconHandle>(null);
|
const fileManagerIconRef = useRef<FilePenIconHandle>(null);
|
||||||
|
const lyricsManagerIconRef = useRef<FileTextIconHandle>(null);
|
||||||
const handleIssuesDialogChange = (open: boolean) => {
|
const handleIssuesDialogChange = (open: boolean) => {
|
||||||
setIsIssuesDialogOpen(open);
|
setIsIssuesDialogOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -99,8 +101,8 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant={["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`}>
|
<Button variant={["audio-analysis", "audio-converter", "audio-resampler", "file-manager", "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"}`}>
|
||||||
<BlocksIcon size={20} loop={true}/>
|
<ToolCaseIcon size={20}/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -125,6 +127,10 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
<FilePenIcon ref={fileManagerIconRef} size={16}/>
|
<FilePenIcon ref={fileManagerIconRef} size={16}/>
|
||||||
<span>File Manager</span>
|
<span>File Manager</span>
|
||||||
</DropdownMenuItem>
|
</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>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,7 +140,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => setIsIssuesDialogOpen(true)}>
|
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => setIsIssuesDialogOpen(true)}>
|
||||||
<GithubIcon size={20}/>
|
<BugReportIcon size={20} loop={true}/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
@@ -176,23 +182,23 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
|
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant={currentPage === "about" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "about" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("about")}>
|
<Button variant={currentPage === "projects" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "projects" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("projects")}>
|
||||||
<BadgeAlertIcon size={20}/>
|
<BlocksIcon size={20} loop={true}/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
<p>About</p>
|
<p>Other Projects</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
<Button variant={currentPage === "support" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "support" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("support")}>
|
||||||
<CoffeeIcon size={20} loop={true}/>
|
<CoffeeIcon size={20} loop={true}/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
<p>Support me on Ko-fi</p>
|
<p>Support Me</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { CircleCheck, Copy } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { openExternal } from "@/lib/utils";
|
||||||
|
import KofiLogo from "@/assets/ko-fi.gif";
|
||||||
|
import KofiSvg from "@/assets/kofi_symbol.svg";
|
||||||
|
import PatreonLogo from "@/assets/patreon.svg";
|
||||||
|
import PatreonSymbol from "@/assets/patreon_symbol.svg";
|
||||||
|
import UsdtBarcode from "@/assets/usdt.jpg";
|
||||||
|
export function SupportPage() {
|
||||||
|
const [copiedUsdt, setCopiedUsdt] = useState(false);
|
||||||
|
const [copiedEmail, setCopiedEmail] = useState(false);
|
||||||
|
return (<div className="flex flex-col space-y-3">
|
||||||
|
<div className="flex items-center justify-between shrink-0">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Support Me</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-center p-4">
|
||||||
|
<div className="grid w-full max-w-5xl overflow-hidden rounded-xl border bg-card shadow-sm md:grid-cols-3">
|
||||||
|
<div className="flex min-h-[22rem] flex-col items-center justify-between space-y-6 border-b p-6 md:border-b-0 md:border-r">
|
||||||
|
<div className="flex flex-col items-center space-y-4">
|
||||||
|
<div className="h-32 flex items-center justify-center w-full relative">
|
||||||
|
<img src={KofiLogo} className="w-72 absolute pointer-events-none" alt="Ko-fi"/>
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold text-foreground">Support via Ko-fi</h4>
|
||||||
|
<p className="text-sm text-muted-foreground text-center px-4">
|
||||||
|
Buy me a coffee to help keep development going.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button className="h-10 w-full gap-2 bg-[#72a4f2] text-sm font-semibold text-white hover:bg-[#5f8cd6]" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
||||||
|
<img src={KofiSvg} className="h-6 w-6 shrink-0" alt="" aria-hidden="true"/>
|
||||||
|
Support me on Ko-fi
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-[22rem] flex-col items-center justify-between space-y-6 border-b p-6 md:border-b-0 md:border-r">
|
||||||
|
<div className="flex flex-col items-center space-y-4 w-full">
|
||||||
|
<div className="h-32 flex items-center justify-center w-full px-4">
|
||||||
|
<img src={PatreonLogo} className="w-56 max-w-full brightness-0 dark:brightness-100" alt="Patreon"/>
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold text-foreground">Support via Patreon</h4>
|
||||||
|
<p className="text-sm text-muted-foreground text-center px-4">
|
||||||
|
Join on Patreon to help fund the project and follow updates.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button className="h-10 w-full gap-2 bg-[#ff424d] text-sm font-semibold text-white hover:bg-[#e63945]" onClick={() => openExternal("https://www.patreon.com/cw/afkarxyz")}>
|
||||||
|
<img src={PatreonSymbol} className="h-5 w-5 shrink-0" alt="" aria-hidden="true"/>
|
||||||
|
Support me on Patreon
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-[22rem] flex-col items-center justify-between space-y-6 p-6">
|
||||||
|
<div className="flex flex-col items-center space-y-4 w-full">
|
||||||
|
<div className="h-32 flex items-center justify-center">
|
||||||
|
<div className="rounded-xl border bg-white p-2 shadow-sm">
|
||||||
|
<img src={UsdtBarcode} className="h-24 w-24 object-contain" alt="USDT Barcode"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold text-foreground">USDT (TRC20)</h4>
|
||||||
|
<p className="text-sm text-muted-foreground text-center px-4">
|
||||||
|
Prefer crypto? Use the QR code or wallet address below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-10 w-full items-center justify-between gap-2 rounded-lg border bg-muted/50 py-1.5 pl-3 pr-1.5">
|
||||||
|
<code className="truncate text-xs font-mono text-muted-foreground" title="THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs">
|
||||||
|
THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs
|
||||||
|
</code>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 hover:bg-background" onClick={() => {
|
||||||
|
navigator.clipboard.writeText("THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs");
|
||||||
|
setCopiedUsdt(true);
|
||||||
|
setTimeout(() => setCopiedUsdt(false), 500);
|
||||||
|
}}>
|
||||||
|
{copiedUsdt ? <CircleCheck className="h-3.5 w-3.5 text-green-500"/> : <Copy className="h-3.5 w-3.5"/>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 w-full max-w-5xl rounded-xl border bg-muted/30 px-4 py-3 text-center text-sm text-muted-foreground">
|
||||||
|
If you have any questions or need help with donating, feel free to reach out via{" "}
|
||||||
|
<button type="button" className="font-medium text-foreground underline-offset-4 hover:underline" onClick={() => openExternal("https://t.me/afkarxyz")}>
|
||||||
|
Telegram
|
||||||
|
</button>{" "}
|
||||||
|
or{" "}
|
||||||
|
<button type="button" className="font-medium text-foreground underline-offset-4 hover:underline" onClick={() => {
|
||||||
|
navigator.clipboard.writeText("hi@afkarxyz.fyi");
|
||||||
|
setCopiedEmail(true);
|
||||||
|
setTimeout(() => setCopiedEmail(false), 500);
|
||||||
|
}}>
|
||||||
|
{copiedEmail ? "copied" : "hi@afkarxyz.fyi"}
|
||||||
|
</button>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
@@ -176,7 +176,7 @@ export function TitleBar() {
|
|||||||
</div>)}
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
<MenubarSeparator />
|
<MenubarSeparator />
|
||||||
<MenubarItem onClick={() => openExternal("https://afkarxyz.qzz.io")} className="gap-2">
|
<MenubarItem onClick={() => openExternal("https://afkarxyz.fyi")} className="gap-2">
|
||||||
<Globe className="w-4 h-4 opacity-70"/>
|
<Globe className="w-4 h-4 opacity-70"/>
|
||||||
<span>Website</span>
|
<span>Website</span>
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/toolti
|
|||||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||||
import { usePreview } from "@/hooks/usePreview";
|
import { usePreview } from "@/hooks/usePreview";
|
||||||
import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks";
|
import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks";
|
||||||
import { buildClickableArtists } from "@/lib/artist-links";
|
import { buildClickableArtists, getClickableArtistKey } from "@/lib/artist-links";
|
||||||
interface TrackInfoProps {
|
interface TrackInfoProps {
|
||||||
track: TrackMetadata & {
|
track: TrackMetadata & {
|
||||||
album_name: string;
|
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}
|
{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>
|
</div>
|
||||||
<p className="text-lg text-muted-foreground">
|
<p className="text-lg text-muted-foreground">
|
||||||
{clickableArtists.length > 0 ? clickableArtists.map((artist, index) => (<span key={`${artist.id || artist.name}-${index}`}>
|
{clickableArtists.length > 0 ? clickableArtists.map((artist, index) => (<span key={getClickableArtistKey(artist)}>
|
||||||
{onArtistClick ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
|
{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({
|
||||||
id: artist.id,
|
id: artist.id,
|
||||||
name: artist.name,
|
name: artist.name,
|
||||||
external_urls: artist.external_urls,
|
external_urls: artist.external_urls,
|
||||||
})}>
|
})}>
|
||||||
{artist.name}
|
{artist.name}
|
||||||
</span>) : (artist.name)}
|
</button>) : (artist.name)}
|
||||||
{index < clickableArtists.length - 1 && ", "}
|
{index < clickableArtists.length - 1 && ", "}
|
||||||
</span>)) : track.artists}
|
</span>)) : track.artists}
|
||||||
</p>
|
</p>
|
||||||
@@ -99,13 +99,13 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Album</p>
|
<p className="text-xs text-muted-foreground">Album</p>
|
||||||
<p className="font-medium truncate">{hasAlbumClick ? (<span className="cursor-pointer hover:underline" onClick={() => onAlbumClick?.({
|
<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?.({
|
||||||
id: track.album_id!,
|
id: track.album_id!,
|
||||||
name: track.album_name,
|
name: track.album_name,
|
||||||
external_urls: track.album_url!,
|
external_urls: track.album_url!,
|
||||||
})}>
|
})}>
|
||||||
{track.album_name}
|
{track.album_name}
|
||||||
</span>) : (track.album_name)}</p>
|
</button>) : (track.album_name)}</p>
|
||||||
</div>
|
</div>
|
||||||
{track.plays && (<div>
|
{track.plays && (<div>
|
||||||
<p className="text-xs text-muted-foreground">Total Plays</p>
|
<p className="text-xs text-muted-foreground">Total Plays</p>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, Pagi
|
|||||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||||
import { usePreview } from "@/hooks/usePreview";
|
import { usePreview } from "@/hooks/usePreview";
|
||||||
import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks";
|
import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks";
|
||||||
import { buildClickableArtists } from "@/lib/artist-links";
|
import { buildClickableArtists, getClickableArtistKey } from "@/lib/artist-links";
|
||||||
interface TrackListProps {
|
interface TrackListProps {
|
||||||
tracks: TrackMetadata[];
|
tracks: TrackMetadata[];
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -55,6 +55,7 @@ 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) {
|
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 { 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) => {
|
let filteredTracks = tracks.filter((track) => {
|
||||||
if (!searchQuery)
|
if (!searchQuery)
|
||||||
return true;
|
return true;
|
||||||
@@ -219,7 +220,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{paginatedTracks.map((track, index) => (<tr key={index} className="border-b transition-colors hover:bg-muted/50">
|
{paginatedTracks.map((track, index) => (<tr key={getTrackKey(track)} className="border-b transition-colors hover:bg-muted/50">
|
||||||
{showCheckboxes && (<td className="p-4 align-middle">
|
{showCheckboxes && (<td className="p-4 align-middle">
|
||||||
{track.spotify_id && (<Checkbox checked={selectedTracks.includes(track.spotify_id)} onCheckedChange={() => onToggleTrack(track.spotify_id!)}/>)}
|
{track.spotify_id && (<Checkbox checked={selectedTracks.includes(track.spotify_id)} onCheckedChange={() => onToggleTrack(track.spotify_id!)}/>)}
|
||||||
</td>)}
|
</td>)}
|
||||||
@@ -242,9 +243,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"/>)}
|
{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 flex-col">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{onTrackClick ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onTrackClick(track)}>
|
{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)}>
|
||||||
{track.name}
|
{track.name}
|
||||||
</span>) : (<span className="font-medium">{track.name}</span>)}
|
</button>) : (<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.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}
|
{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}
|
||||||
@@ -255,14 +256,14 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
|||||||
if (clickableArtists.length === 0) {
|
if (clickableArtists.length === 0) {
|
||||||
return track.artists;
|
return track.artists;
|
||||||
}
|
}
|
||||||
return clickableArtists.map((artist, i) => (<span key={`${artist.id || artist.name}-${i}`}>
|
return clickableArtists.map((artist, i) => (<span key={getClickableArtistKey(artist)}>
|
||||||
{onArtistClick ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
|
{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({
|
||||||
id: artist.id,
|
id: artist.id,
|
||||||
name: artist.name,
|
name: artist.name,
|
||||||
external_urls: artist.external_urls,
|
external_urls: artist.external_urls,
|
||||||
})}>
|
})}>
|
||||||
{artist.name}
|
{artist.name}
|
||||||
</span>) : (artist.name)}
|
</button>) : (artist.name)}
|
||||||
{i < clickableArtists.length - 1 && ", "}
|
{i < clickableArtists.length - 1 && ", "}
|
||||||
</span>));
|
</span>));
|
||||||
})()}
|
})()}
|
||||||
@@ -271,13 +272,13 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
{!hideAlbumColumn && (<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
{!hideAlbumColumn && (<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
||||||
{onAlbumClick && track.album_id && track.album_url ? (<span className="cursor-pointer hover:underline" onClick={() => onAlbumClick({
|
{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({
|
||||||
id: track.album_id!,
|
id: track.album_id!,
|
||||||
name: track.album_name,
|
name: track.album_name,
|
||||||
external_urls: track.album_url!,
|
external_urls: track.album_url!,
|
||||||
})}>
|
})}>
|
||||||
{track.album_name}
|
{track.album_name}
|
||||||
</span>) : (track.album_name)}
|
</button>) : (track.album_name)}
|
||||||
</td>)}
|
</td>)}
|
||||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
|
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
|
||||||
{formatDuration(track.duration_ms)}
|
{formatDuration(track.duration_ms)}
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import type { Variants } from "motion/react";
|
|
||||||
import { motion, useAnimation } from "motion/react";
|
|
||||||
import type { HTMLAttributes } from "react";
|
|
||||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
export interface BadgeAlertIconHandle {
|
|
||||||
startAnimation: () => void;
|
|
||||||
stopAnimation: () => void;
|
|
||||||
}
|
|
||||||
interface BadgeAlertIconProps extends HTMLAttributes<HTMLDivElement> {
|
|
||||||
size?: number;
|
|
||||||
}
|
|
||||||
const ICON_VARIANTS: Variants = {
|
|
||||||
normal: { scale: 1, rotate: 0 },
|
|
||||||
animate: {
|
|
||||||
scale: [1, 1.1, 1.1, 1.1, 1],
|
|
||||||
rotate: [0, -3, 3, -2, 2, 0],
|
|
||||||
transition: {
|
|
||||||
duration: 0.5,
|
|
||||||
times: [0, 0.2, 0.4, 0.6, 1],
|
|
||||||
ease: "easeInOut",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const BadgeAlertIcon = forwardRef<BadgeAlertIconHandle, BadgeAlertIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
|
||||||
const controls = useAnimation();
|
|
||||||
const isControlledRef = useRef(false);
|
|
||||||
useImperativeHandle(ref, () => {
|
|
||||||
isControlledRef.current = true;
|
|
||||||
return {
|
|
||||||
startAnimation: () => controls.start("animate"),
|
|
||||||
stopAnimation: () => controls.start("normal"),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (isControlledRef.current) {
|
|
||||||
onMouseEnter?.(e);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
controls.start("animate");
|
|
||||||
}
|
|
||||||
}, [controls, onMouseEnter]);
|
|
||||||
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (isControlledRef.current) {
|
|
||||||
onMouseLeave?.(e);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
controls.start("normal");
|
|
||||||
}
|
|
||||||
}, [controls, onMouseLeave]);
|
|
||||||
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
|
||||||
<motion.svg animate={controls} fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" variants={ICON_VARIANTS} viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/>
|
|
||||||
<line x1="12" x2="12" y1="8" y2="12"/>
|
|
||||||
<line x1="12" x2="12.01" y1="16" y2="16"/>
|
|
||||||
</motion.svg>
|
|
||||||
</div>);
|
|
||||||
});
|
|
||||||
BadgeAlertIcon.displayName = "BadgeAlertIcon";
|
|
||||||
export { BadgeAlertIcon };
|
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"use client";
|
||||||
|
import type { Transition, Variants } from "motion/react";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
import { useEffect, useState, type HTMLAttributes } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
type ReportIconMode = "bug" | "bulb";
|
||||||
|
interface BugReportIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
size?: number;
|
||||||
|
loop?: boolean;
|
||||||
|
}
|
||||||
|
const LOOP_INTERVAL_MS = 2200;
|
||||||
|
const GROUP_VARIANTS: Variants = {
|
||||||
|
hidden: {
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 0.2,
|
||||||
|
ease: [0, 0, 0.2, 1],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
opacity: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.18,
|
||||||
|
ease: [0.4, 0, 1, 1],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const DRAW_VARIANTS: Variants = {
|
||||||
|
hidden: {
|
||||||
|
pathLength: 0,
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
visible: {
|
||||||
|
pathLength: 1,
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
pathLength: 1,
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
function createDrawTransition(delay = 0, duration = 0.36): Transition {
|
||||||
|
return {
|
||||||
|
duration,
|
||||||
|
delay,
|
||||||
|
ease: [0.4, 0, 0.2, 1],
|
||||||
|
opacity: { delay },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function BugPaths() {
|
||||||
|
return (<>
|
||||||
|
<motion.path d="m8 2 1.88 1.88" transition={createDrawTransition(0)} variants={DRAW_VARIANTS}/>
|
||||||
|
<motion.path d="M14.12 3.88 16 2" transition={createDrawTransition(0.04)} variants={DRAW_VARIANTS}/>
|
||||||
|
<motion.path d="M9 7.13V6a3 3 0 1 1 6 0v1.13" transition={createDrawTransition(0.08)} variants={DRAW_VARIANTS}/>
|
||||||
|
<motion.path d="M6.53 9A4 4 0 0 1 3 5" transition={createDrawTransition(0.14)} variants={DRAW_VARIANTS}/>
|
||||||
|
<motion.path d="M17.47 9A4 4 0 0 0 21 5" transition={createDrawTransition(0.18)} variants={DRAW_VARIANTS}/>
|
||||||
|
<motion.path d="M12 20v-9" transition={createDrawTransition(0.24)} variants={DRAW_VARIANTS}/>
|
||||||
|
<motion.path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" transition={createDrawTransition(0.3, 0.42)} variants={DRAW_VARIANTS}/>
|
||||||
|
<motion.path d="M22 13h-4" transition={createDrawTransition(0.42)} variants={DRAW_VARIANTS}/>
|
||||||
|
<motion.path d="M6 13H2" transition={createDrawTransition(0.46)} variants={DRAW_VARIANTS}/>
|
||||||
|
<motion.path d="M21 21a4 4 0 0 0-3.81-4" transition={createDrawTransition(0.52)} variants={DRAW_VARIANTS}/>
|
||||||
|
<motion.path d="M3 21a4 4 0 0 1 3.81-4" transition={createDrawTransition(0.56)} variants={DRAW_VARIANTS}/>
|
||||||
|
</>);
|
||||||
|
}
|
||||||
|
function BulbPaths() {
|
||||||
|
return (<>
|
||||||
|
<motion.path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5" transition={createDrawTransition(0, 0.46)} variants={DRAW_VARIANTS}/>
|
||||||
|
<motion.path d="M9 18h6" transition={createDrawTransition(0.16)} variants={DRAW_VARIANTS}/>
|
||||||
|
<motion.path d="M10 22h4" transition={createDrawTransition(0.24)} variants={DRAW_VARIANTS}/>
|
||||||
|
</>);
|
||||||
|
}
|
||||||
|
function ReportIconGroup({ mode }: {
|
||||||
|
mode: ReportIconMode;
|
||||||
|
}) {
|
||||||
|
return (<motion.g animate="visible" exit="exit" initial="hidden" variants={GROUP_VARIANTS}>
|
||||||
|
{mode === "bug" ? <BugPaths /> : <BulbPaths />}
|
||||||
|
</motion.g>);
|
||||||
|
}
|
||||||
|
function StaticBugIcon() {
|
||||||
|
return (<g>
|
||||||
|
<path d="m8 2 1.88 1.88"/>
|
||||||
|
<path d="M14.12 3.88 16 2"/>
|
||||||
|
<path d="M9 7.13V6a3 3 0 1 1 6 0v1.13"/>
|
||||||
|
<path d="M6.53 9A4 4 0 0 1 3 5"/>
|
||||||
|
<path d="M17.47 9A4 4 0 0 0 21 5"/>
|
||||||
|
<path d="M12 20v-9"/>
|
||||||
|
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z"/>
|
||||||
|
<path d="M22 13h-4"/>
|
||||||
|
<path d="M6 13H2"/>
|
||||||
|
<path d="M21 21a4 4 0 0 0-3.81-4"/>
|
||||||
|
<path d="M3 21a4 4 0 0 1 3.81-4"/>
|
||||||
|
</g>);
|
||||||
|
}
|
||||||
|
function BugReportIcon({ className, size = 28, loop = false, ...props }: BugReportIconProps) {
|
||||||
|
const [mode, setMode] = useState<ReportIconMode>("bug");
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loop) {
|
||||||
|
setMode("bug");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
setMode((currentMode) => currentMode === "bug" ? "bulb" : "bug");
|
||||||
|
}, LOOP_INTERVAL_MS);
|
||||||
|
return () => window.clearInterval(intervalId);
|
||||||
|
}, [loop]);
|
||||||
|
return (<div className={cn("flex items-center justify-center", className)} {...props}>
|
||||||
|
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
|
||||||
|
{loop ? (<AnimatePresence>
|
||||||
|
<ReportIconGroup key={mode} mode={mode}/>
|
||||||
|
</AnimatePresence>) : (<StaticBugIcon />)}
|
||||||
|
</svg>
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
|
export { BugReportIcon };
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
'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 };
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import type { Variants } from "motion/react";
|
|
||||||
import { motion, useAnimation } from "motion/react";
|
|
||||||
import type { HTMLAttributes } from "react";
|
|
||||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
export interface GithubIconHandle {
|
|
||||||
startAnimation: () => void;
|
|
||||||
stopAnimation: () => void;
|
|
||||||
}
|
|
||||||
interface GithubIconProps extends HTMLAttributes<HTMLDivElement> {
|
|
||||||
size?: number;
|
|
||||||
}
|
|
||||||
const BODY_VARIANTS: Variants = {
|
|
||||||
normal: {
|
|
||||||
opacity: 1,
|
|
||||||
pathLength: 1,
|
|
||||||
scale: 1,
|
|
||||||
transition: {
|
|
||||||
duration: 0.3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
animate: {
|
|
||||||
opacity: [0, 1],
|
|
||||||
pathLength: [0, 1],
|
|
||||||
scale: [0.9, 1],
|
|
||||||
transition: {
|
|
||||||
duration: 0.4,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const TAIL_VARIANTS: Variants = {
|
|
||||||
normal: {
|
|
||||||
pathLength: 1,
|
|
||||||
rotate: 0,
|
|
||||||
transition: {
|
|
||||||
duration: 0.3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
draw: {
|
|
||||||
pathLength: [0, 1],
|
|
||||||
rotate: 0,
|
|
||||||
transition: {
|
|
||||||
duration: 0.5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
wag: {
|
|
||||||
pathLength: 1,
|
|
||||||
rotate: [0, -15, 15, -10, 10, -5, 5],
|
|
||||||
transition: {
|
|
||||||
duration: 2.5,
|
|
||||||
ease: "easeInOut",
|
|
||||||
repeat: Number.POSITIVE_INFINITY,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const GithubIcon = forwardRef<GithubIconHandle, GithubIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
|
||||||
const bodyControls = useAnimation();
|
|
||||||
const tailControls = useAnimation();
|
|
||||||
const isControlledRef = useRef(false);
|
|
||||||
useImperativeHandle(ref, () => {
|
|
||||||
isControlledRef.current = true;
|
|
||||||
return {
|
|
||||||
startAnimation: async () => {
|
|
||||||
bodyControls.start("animate");
|
|
||||||
await tailControls.start("draw");
|
|
||||||
tailControls.start("wag");
|
|
||||||
},
|
|
||||||
stopAnimation: () => {
|
|
||||||
bodyControls.start("normal");
|
|
||||||
tailControls.start("normal");
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const handleMouseEnter = useCallback(async (e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (isControlledRef.current) {
|
|
||||||
onMouseEnter?.(e);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
bodyControls.start("animate");
|
|
||||||
await tailControls.start("draw");
|
|
||||||
tailControls.start("wag");
|
|
||||||
}
|
|
||||||
}, [bodyControls, onMouseEnter, tailControls]);
|
|
||||||
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (isControlledRef.current) {
|
|
||||||
onMouseLeave?.(e);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
bodyControls.start("normal");
|
|
||||||
tailControls.start("normal");
|
|
||||||
}
|
|
||||||
}, [bodyControls, tailControls, onMouseLeave]);
|
|
||||||
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
|
||||||
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<motion.path animate={bodyControls} d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" initial="normal" variants={BODY_VARIANTS}/>
|
|
||||||
<motion.path animate={tailControls} d="M9 18c-4.51 2-5-2-7-2" initial="normal" variants={TAIL_VARIANTS}/>
|
|
||||||
</svg>
|
|
||||||
</div>);
|
|
||||||
});
|
|
||||||
GithubIcon.displayName = "GithubIcon";
|
|
||||||
export { GithubIcon };
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
'use client';
|
||||||
|
import type { Variants } from 'motion/react';
|
||||||
|
import type { HTMLAttributes } from 'react';
|
||||||
|
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||||
|
import { motion, useAnimation } from 'motion/react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
export interface ToolCaseIconHandle {
|
||||||
|
startAnimation: () => void;
|
||||||
|
stopAnimation: () => void;
|
||||||
|
}
|
||||||
|
interface ToolCaseIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
const DRAW_VARIANTS: Variants = {
|
||||||
|
normal: {
|
||||||
|
pathLength: 1,
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
pathLength: [0, 1],
|
||||||
|
opacity: [0, 1],
|
||||||
|
transition: {
|
||||||
|
duration: 0.6,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const HANDLE_VARIANTS: Variants = {
|
||||||
|
normal: {
|
||||||
|
scaleX: 1,
|
||||||
|
originX: '50%',
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
scaleX: [0.6, 1.1, 1],
|
||||||
|
originX: '50%',
|
||||||
|
transition: {
|
||||||
|
duration: 0.45,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ToolCaseIcon = forwardRef<ToolCaseIconHandle, ToolCaseIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||||
|
const controls = useAnimation();
|
||||||
|
const isControlledRef = useRef(false);
|
||||||
|
useImperativeHandle(ref, () => {
|
||||||
|
isControlledRef.current = true;
|
||||||
|
return {
|
||||||
|
startAnimation: () => controls.start('animate'),
|
||||||
|
stopAnimation: () => controls.start('normal'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!isControlledRef.current) {
|
||||||
|
controls.start('animate');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
onMouseEnter?.(e);
|
||||||
|
}
|
||||||
|
}, [controls, onMouseEnter]);
|
||||||
|
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!isControlledRef.current) {
|
||||||
|
controls.start('normal');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
onMouseLeave?.(e);
|
||||||
|
}
|
||||||
|
}, [controls, onMouseLeave]);
|
||||||
|
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<motion.path d="M10 15h4" variants={HANDLE_VARIANTS} animate={controls} initial="normal"/>
|
||||||
|
<motion.path d="m14.817 10.995-.971-1.45 1.034-1.232a2 2 0 0 0-2.025-3.238l-1.82.364L9.91 3.885a2 2 0 0 0-3.625.748L6.141 6.55l-1.725.426a2 2 0 0 0-.19 3.756l.657.27" variants={DRAW_VARIANTS} animate={controls} initial="normal"/>
|
||||||
|
<motion.path d="m18.822 10.995 2.26-5.38a1 1 0 0 0-.557-1.318L16.954 2.9a1 1 0 0 0-1.281.533l-.924 2.122" variants={DRAW_VARIANTS} animate={controls} initial="normal"/>
|
||||||
|
<motion.path d="M4 12.006A1 1 0 0 1 4.994 11H19a1 1 0 0 1 1 1v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" variants={DRAW_VARIANTS} animate={controls} initial="normal"/>
|
||||||
|
</svg>
|
||||||
|
</div>);
|
||||||
|
});
|
||||||
|
ToolCaseIcon.displayName = 'ToolCaseIcon';
|
||||||
|
export { ToolCaseIcon };
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { API_SOURCES, checkApiStatus, getApiStatusState, subscribeApiStatus, } from "@/lib/api-status";
|
import { API_SOURCES, checkApiStatus, checkCurrentApiStatusesOnly, checkSpotiFLACNextStatusesOnly, getApiStatusState, subscribeApiStatus, } from "@/lib/api-status";
|
||||||
export function useApiStatus() {
|
export function useApiStatus() {
|
||||||
const [state, setState] = useState(getApiStatusState);
|
const [state, setState] = useState(getApiStatusState);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -11,5 +11,7 @@ export function useApiStatus() {
|
|||||||
...state,
|
...state,
|
||||||
sources: API_SOURCES,
|
sources: API_SOURCES,
|
||||||
checkOne: (sourceId: string) => checkApiStatus(sourceId),
|
checkOne: (sourceId: string) => checkApiStatus(sourceId),
|
||||||
|
checkAllCurrent: () => checkCurrentApiStatusesOnly(),
|
||||||
|
checkAllNext: () => checkSpotiFLACNextStatusesOnly(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+324
-285
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { downloadTrack, fetchSpotifyMetadata } from "@/lib/api";
|
import { downloadTrack, fetchSpotifyMetadata } from "@/lib/api";
|
||||||
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
|
import { getSettings, parseTemplate, sanitizeAutoOrder, type TemplateData } from "@/lib/settings";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils";
|
import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
@@ -92,6 +92,9 @@ export function useDownload(region: string) {
|
|||||||
const customTidalApi = typeof settings.customTidalApi === "string" && settings.customTidalApi.trim().startsWith("https://")
|
const customTidalApi = typeof settings.customTidalApi === "string" && settings.customTidalApi.trim().startsWith("https://")
|
||||||
? settings.customTidalApi.trim().replace(/\/+$/g, "")
|
? settings.customTidalApi.trim().replace(/\/+$/g, "")
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const customQobuzApi = typeof settings.customQobuzApi === "string" && settings.customQobuzApi.trim().startsWith("https://")
|
||||||
|
? settings.customQobuzApi.trim().replace(/\/+$/g, "")
|
||||||
|
: undefined;
|
||||||
let outputDir = settings.downloadPath;
|
let outputDir = settings.downloadPath;
|
||||||
let useAlbumTrackNumber = false;
|
let useAlbumTrackNumber = false;
|
||||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||||
@@ -193,7 +196,303 @@ export function useDownload(region: string) {
|
|||||||
itemID = await AddToDownloadQueue(id, trackName || "", displayArtist || "", albumName || "");
|
itemID = await AddToDownloadQueue(id, trackName || "", displayArtist || "", albumName || "");
|
||||||
}
|
}
|
||||||
if (service === "auto") {
|
if (service === "auto") {
|
||||||
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
const order = sanitizeAutoOrder(settings.autoOrder).split("-");
|
||||||
|
let streamingURLs: any = null;
|
||||||
|
if (spotifyId && shouldFetchStreamingURLs(order)) {
|
||||||
|
try {
|
||||||
|
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
|
||||||
|
const urlsJson = await GetStreamingURLs(spotifyId, region);
|
||||||
|
streamingURLs = JSON.parse(urlsJson);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("Failed to get streaming URLs:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||||
|
let lastResponse: any = { success: false, error: "No matching services found" };
|
||||||
|
const fallbackErrors: string[] = [];
|
||||||
|
const tidalQuality = getTidalAudioFormat(settings, "auto");
|
||||||
|
const is24Bit = (settings.autoQuality || "24") === "24";
|
||||||
|
const qobuzQuality = is24Bit ? "27" : "6";
|
||||||
|
for (const s of order) {
|
||||||
|
if (s === "tidal" && streamingURLs?.tidal_url) {
|
||||||
|
try {
|
||||||
|
logger.debug(`trying Tidal for: ${trackName} - ${artistName}`);
|
||||||
|
const response = await downloadTrack({
|
||||||
|
service: "tidal",
|
||||||
|
query,
|
||||||
|
track_name: trackName,
|
||||||
|
artist_name: displayArtist,
|
||||||
|
album_name: albumName,
|
||||||
|
album_artist: displayAlbumArtist,
|
||||||
|
release_date: finalReleaseDate || releaseDate,
|
||||||
|
cover_url: coverUrl,
|
||||||
|
output_dir: outputDir,
|
||||||
|
filename_format: settings.filenameTemplate,
|
||||||
|
track_number: settings.trackNumber,
|
||||||
|
position,
|
||||||
|
use_album_track_number: useAlbumTrackNumber,
|
||||||
|
spotify_id: spotifyId,
|
||||||
|
embed_lyrics: settings.embedLyrics,
|
||||||
|
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||||
|
service_url: streamingURLs?.tidal_url,
|
||||||
|
duration: durationSeconds,
|
||||||
|
item_id: itemID,
|
||||||
|
audio_format: tidalQuality,
|
||||||
|
tidal_api_url: customTidalApi,
|
||||||
|
spotify_track_number: spotifyTrackNumber,
|
||||||
|
spotify_disc_number: spotifyDiscNumber,
|
||||||
|
spotify_total_tracks: spotifyTotalTracks,
|
||||||
|
spotify_total_discs: spotifyTotalDiscs,
|
||||||
|
isrc: resolvedTemplateISRC || undefined,
|
||||||
|
copyright: copyright,
|
||||||
|
publisher: publisher,
|
||||||
|
use_first_artist_only: settings.useFirstArtistOnly,
|
||||||
|
use_single_genre: settings.useSingleGenre,
|
||||||
|
embed_genre: settings.embedGenre,
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
logger.success(`Tidal: ${trackName} - ${artistName}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
const errMsg = response.error || response.message || "Failed";
|
||||||
|
fallbackErrors.push(`[Tidal] ${errMsg}`);
|
||||||
|
lastResponse = response;
|
||||||
|
logger.warning(`Tidal failed, trying next...`);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
logger.error(`Tidal error: ${err}`);
|
||||||
|
fallbackErrors.push(`[Tidal] ${String(err)}`);
|
||||||
|
lastResponse = { success: false, error: String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (s === "amazon" && streamingURLs?.amazon_url) {
|
||||||
|
try {
|
||||||
|
logger.debug(`trying amazon for: ${trackName} - ${artistName}`);
|
||||||
|
const response = await downloadTrack({
|
||||||
|
service: "amazon",
|
||||||
|
query,
|
||||||
|
track_name: trackName,
|
||||||
|
artist_name: displayArtist,
|
||||||
|
album_name: albumName,
|
||||||
|
album_artist: displayAlbumArtist,
|
||||||
|
release_date: finalReleaseDate || releaseDate,
|
||||||
|
cover_url: coverUrl,
|
||||||
|
output_dir: outputDir,
|
||||||
|
filename_format: settings.filenameTemplate,
|
||||||
|
track_number: settings.trackNumber,
|
||||||
|
position,
|
||||||
|
use_album_track_number: useAlbumTrackNumber,
|
||||||
|
spotify_id: spotifyId,
|
||||||
|
embed_lyrics: settings.embedLyrics,
|
||||||
|
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,
|
||||||
|
spotify_total_discs: spotifyTotalDiscs,
|
||||||
|
isrc: resolvedTemplateISRC || undefined,
|
||||||
|
copyright: copyright,
|
||||||
|
publisher: publisher,
|
||||||
|
use_single_genre: settings.useSingleGenre,
|
||||||
|
embed_genre: settings.embedGenre,
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
logger.success(`amazon: ${trackName} - ${artistName}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
const errMsg = response.error || response.message || "Failed";
|
||||||
|
fallbackErrors.push(`[Amazon] ${errMsg}`);
|
||||||
|
lastResponse = response;
|
||||||
|
logger.warning(`amazon failed, trying next...`);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
logger.error(`amazon error: ${err}`);
|
||||||
|
fallbackErrors.push(`[Amazon] ${String(err)}`);
|
||||||
|
lastResponse = { success: false, error: String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (s === "qobuz") {
|
||||||
|
try {
|
||||||
|
logger.debug(`trying qobuz for: ${trackName} - ${artistName}`);
|
||||||
|
const response = await downloadTrack({
|
||||||
|
service: "qobuz",
|
||||||
|
query,
|
||||||
|
track_name: trackName,
|
||||||
|
artist_name: displayArtist,
|
||||||
|
album_name: albumName,
|
||||||
|
album_artist: displayAlbumArtist,
|
||||||
|
release_date: finalReleaseDate || releaseDate,
|
||||||
|
cover_url: coverUrl,
|
||||||
|
output_dir: outputDir,
|
||||||
|
filename_format: settings.filenameTemplate,
|
||||||
|
track_number: settings.trackNumber,
|
||||||
|
position: trackNumberForTemplate,
|
||||||
|
use_album_track_number: useAlbumTrackNumber,
|
||||||
|
spotify_id: spotifyId,
|
||||||
|
embed_lyrics: settings.embedLyrics,
|
||||||
|
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,
|
||||||
|
spotify_total_discs: spotifyTotalDiscs,
|
||||||
|
isrc: resolvedTemplateISRC || undefined,
|
||||||
|
copyright: copyright,
|
||||||
|
publisher: publisher,
|
||||||
|
use_single_genre: settings.useSingleGenre,
|
||||||
|
embed_genre: settings.embedGenre,
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
logger.success(`qobuz: ${trackName} - ${artistName}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
const errMsg = response.error || response.message || "Failed";
|
||||||
|
fallbackErrors.push(`[Qobuz] ${errMsg}`);
|
||||||
|
lastResponse = response;
|
||||||
|
logger.warning(`qobuz failed, trying next...`);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
logger.error(`qobuz error: ${err}`);
|
||||||
|
fallbackErrors.push(`[Qobuz] ${String(err)}`);
|
||||||
|
lastResponse = { success: false, error: String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (itemID) {
|
||||||
|
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
||||||
|
const finalError = fallbackErrors.length > 0 ? fallbackErrors.join(" | ") : (lastResponse.error || "All services failed");
|
||||||
|
await MarkDownloadItemFailed(itemID, finalError);
|
||||||
|
}
|
||||||
|
return lastResponse;
|
||||||
|
}
|
||||||
|
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||||
|
let audioFormat: string | undefined;
|
||||||
|
if (service === "tidal") {
|
||||||
|
audioFormat = getTidalAudioFormat(settings, "single");
|
||||||
|
}
|
||||||
|
else if (service === "qobuz") {
|
||||||
|
audioFormat = settings.qobuzQuality || "6";
|
||||||
|
}
|
||||||
|
else if (service === "amazon") {
|
||||||
|
audioFormat = settings.amazonQuality || "16";
|
||||||
|
}
|
||||||
|
else if (service === "deezer") {
|
||||||
|
audioFormat = "flac";
|
||||||
|
}
|
||||||
|
logger.debug(`trying ${service} for: ${trackName} - ${artistName}`);
|
||||||
|
const singleServiceResponse = await downloadTrack({
|
||||||
|
service: service as "tidal" | "qobuz" | "amazon",
|
||||||
|
query,
|
||||||
|
track_name: trackName,
|
||||||
|
artist_name: displayArtist,
|
||||||
|
album_name: albumName,
|
||||||
|
album_artist: displayAlbumArtist,
|
||||||
|
release_date: finalReleaseDate || releaseDate,
|
||||||
|
cover_url: coverUrl,
|
||||||
|
output_dir: outputDir,
|
||||||
|
filename_format: settings.filenameTemplate,
|
||||||
|
track_number: settings.trackNumber,
|
||||||
|
position: trackNumberForTemplate,
|
||||||
|
use_album_track_number: useAlbumTrackNumber,
|
||||||
|
spotify_id: spotifyId,
|
||||||
|
embed_lyrics: settings.embedLyrics,
|
||||||
|
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||||
|
duration: durationSecondsForFallback,
|
||||||
|
item_id: itemID,
|
||||||
|
audio_format: audioFormat,
|
||||||
|
tidal_api_url: service === "tidal" ? customTidalApi : undefined,
|
||||||
|
qobuz_api_url: service === "qobuz" ? customQobuzApi : undefined,
|
||||||
|
spotify_track_number: spotifyTrackNumber,
|
||||||
|
spotify_disc_number: spotifyDiscNumber,
|
||||||
|
spotify_total_tracks: spotifyTotalTracks,
|
||||||
|
spotify_total_discs: spotifyTotalDiscs,
|
||||||
|
isrc: resolvedTemplateISRC || undefined,
|
||||||
|
copyright: copyright,
|
||||||
|
publisher: publisher,
|
||||||
|
use_first_artist_only: settings.useFirstArtistOnly,
|
||||||
|
use_single_genre: settings.useSingleGenre,
|
||||||
|
embed_genre: settings.embedGenre,
|
||||||
|
});
|
||||||
|
if (!singleServiceResponse.success && itemID) {
|
||||||
|
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
||||||
|
await MarkDownloadItemFailed(itemID, singleServiceResponse.error || "Download failed");
|
||||||
|
}
|
||||||
|
return singleServiceResponse;
|
||||||
|
};
|
||||||
|
const downloadWithItemID = async (settings: any, itemID: string, trackName?: string, artistName?: string, albumName?: string, folderName?: string, position?: number, spotifyId?: string, durationMs?: number, isAlbum?: boolean, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
|
||||||
|
const service = settings.downloader;
|
||||||
|
const 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__";
|
||||||
|
let finalReleaseDate = releaseDate;
|
||||||
|
let finalTrackNumber = spotifyTrackNumber || 0;
|
||||||
|
if (spotifyId) {
|
||||||
|
try {
|
||||||
|
const trackURL = `https://open.spotify.com/track/${spotifyId}`;
|
||||||
|
const trackMetadata = await fetchSpotifyMetadata(trackURL, false, 0, 10);
|
||||||
|
if ("track" in trackMetadata && trackMetadata.track) {
|
||||||
|
if (trackMetadata.track.release_date) {
|
||||||
|
finalReleaseDate = trackMetadata.track.release_date;
|
||||||
|
}
|
||||||
|
if (trackMetadata.track.track_number > 0) {
|
||||||
|
finalTrackNumber = trackMetadata.track.track_number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const yearValue = releaseYear || finalReleaseDate?.substring(0, 4);
|
||||||
|
const hasSubfolder = settings.folderTemplate && settings.folderTemplate.trim() !== "";
|
||||||
|
const trackNumberForTemplate = (hasSubfolder && finalTrackNumber > 0) ? finalTrackNumber : (position || 0);
|
||||||
|
const displayArtist = settings.useFirstArtistOnly && artistName
|
||||||
|
? getFirstArtist(artistName)
|
||||||
|
: artistName;
|
||||||
|
const displayAlbumArtist = settings.useFirstArtistOnly && albumArtist
|
||||||
|
? getFirstArtist(albumArtist)
|
||||||
|
: albumArtist;
|
||||||
|
const resolvedTemplateISRC = await resolveTemplateISRC(settings, spotifyId);
|
||||||
|
const templateData: TemplateData = {
|
||||||
|
artist: displayArtist?.replace(/\//g, placeholder),
|
||||||
|
album: albumName?.replace(/\//g, placeholder),
|
||||||
|
album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
|
||||||
|
title: trackName?.replace(/\//g, placeholder),
|
||||||
|
isrc: resolvedTemplateISRC?.replace(/\//g, placeholder),
|
||||||
|
track: trackNumberForTemplate,
|
||||||
|
year: yearValue,
|
||||||
|
date: releaseDate,
|
||||||
|
playlist: folderName?.replace(/\//g, placeholder),
|
||||||
|
};
|
||||||
|
const folderTemplate = settings.folderTemplate || "";
|
||||||
|
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
||||||
|
if (settings.createPlaylistFolder && folderName && (!isAlbum || !useAlbumSubfolder)) {
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
|
||||||
|
}
|
||||||
|
if (settings.folderTemplate) {
|
||||||
|
const folderPath = parseTemplate(settings.folderTemplate, templateData);
|
||||||
|
if (folderPath) {
|
||||||
|
const parts = folderPath.split("/").filter(p => p.trim());
|
||||||
|
for (const part of parts) {
|
||||||
|
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (service === "auto") {
|
||||||
|
const order = sanitizeAutoOrder(settings.autoOrder).split("-");
|
||||||
let streamingURLs: any = null;
|
let streamingURLs: any = null;
|
||||||
if (spotifyId && shouldFetchStreamingURLs(order)) {
|
if (spotifyId && shouldFetchStreamingURLs(order)) {
|
||||||
try {
|
try {
|
||||||
@@ -263,289 +562,6 @@ export function useDownload(region: string) {
|
|||||||
lastResponse = { success: false, error: String(err) };
|
lastResponse = { success: false, error: String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (s === "amazon" && streamingURLs?.amazon_url) {
|
|
||||||
try {
|
|
||||||
logger.debug(`trying amazon for: ${trackName} - ${artistName}`);
|
|
||||||
const response = await downloadTrack({
|
|
||||||
service: "amazon",
|
|
||||||
query,
|
|
||||||
track_name: trackName,
|
|
||||||
artist_name: displayArtist,
|
|
||||||
album_name: albumName,
|
|
||||||
album_artist: displayAlbumArtist,
|
|
||||||
release_date: finalReleaseDate || releaseDate,
|
|
||||||
cover_url: coverUrl,
|
|
||||||
output_dir: outputDir,
|
|
||||||
filename_format: settings.filenameTemplate,
|
|
||||||
track_number: settings.trackNumber,
|
|
||||||
position,
|
|
||||||
use_album_track_number: useAlbumTrackNumber,
|
|
||||||
spotify_id: spotifyId,
|
|
||||||
embed_lyrics: settings.embedLyrics,
|
|
||||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
|
||||||
service_url: streamingURLs.amazon_url,
|
|
||||||
item_id: itemID,
|
|
||||||
spotify_track_number: spotifyTrackNumber,
|
|
||||||
spotify_disc_number: spotifyDiscNumber,
|
|
||||||
spotify_total_tracks: spotifyTotalTracks,
|
|
||||||
spotify_total_discs: spotifyTotalDiscs,
|
|
||||||
isrc: resolvedTemplateISRC || undefined,
|
|
||||||
copyright: copyright,
|
|
||||||
publisher: publisher,
|
|
||||||
use_single_genre: settings.useSingleGenre,
|
|
||||||
embed_genre: settings.embedGenre,
|
|
||||||
});
|
|
||||||
if (response.success) {
|
|
||||||
logger.success(`amazon: ${trackName} - ${artistName}`);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
const errMsg = response.error || response.message || "Failed";
|
|
||||||
fallbackErrors.push(`[Amazon] ${errMsg}`);
|
|
||||||
lastResponse = response;
|
|
||||||
logger.warning(`amazon failed, trying next...`);
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
logger.error(`amazon error: ${err}`);
|
|
||||||
fallbackErrors.push(`[Amazon] ${String(err)}`);
|
|
||||||
lastResponse = { success: false, error: String(err) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (s === "qobuz") {
|
|
||||||
try {
|
|
||||||
logger.debug(`trying qobuz for: ${trackName} - ${artistName}`);
|
|
||||||
const response = await downloadTrack({
|
|
||||||
service: "qobuz",
|
|
||||||
query,
|
|
||||||
track_name: trackName,
|
|
||||||
artist_name: displayArtist,
|
|
||||||
album_name: albumName,
|
|
||||||
album_artist: displayAlbumArtist,
|
|
||||||
release_date: finalReleaseDate || releaseDate,
|
|
||||||
cover_url: coverUrl,
|
|
||||||
output_dir: outputDir,
|
|
||||||
filename_format: settings.filenameTemplate,
|
|
||||||
track_number: settings.trackNumber,
|
|
||||||
position: trackNumberForTemplate,
|
|
||||||
use_album_track_number: useAlbumTrackNumber,
|
|
||||||
spotify_id: spotifyId,
|
|
||||||
embed_lyrics: settings.embedLyrics,
|
|
||||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
|
||||||
item_id: itemID,
|
|
||||||
audio_format: qobuzQuality,
|
|
||||||
spotify_track_number: spotifyTrackNumber,
|
|
||||||
spotify_disc_number: spotifyDiscNumber,
|
|
||||||
spotify_total_tracks: spotifyTotalTracks,
|
|
||||||
spotify_total_discs: spotifyTotalDiscs,
|
|
||||||
isrc: resolvedTemplateISRC || undefined,
|
|
||||||
copyright: copyright,
|
|
||||||
publisher: publisher,
|
|
||||||
use_single_genre: settings.useSingleGenre,
|
|
||||||
embed_genre: settings.embedGenre,
|
|
||||||
});
|
|
||||||
if (response.success) {
|
|
||||||
logger.success(`qobuz: ${trackName} - ${artistName}`);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
const errMsg = response.error || response.message || "Failed";
|
|
||||||
fallbackErrors.push(`[Qobuz] ${errMsg}`);
|
|
||||||
lastResponse = response;
|
|
||||||
logger.warning(`qobuz failed, trying next...`);
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
logger.error(`qobuz error: ${err}`);
|
|
||||||
fallbackErrors.push(`[Qobuz] ${String(err)}`);
|
|
||||||
lastResponse = { success: false, error: String(err) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (itemID) {
|
|
||||||
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
|
||||||
const finalError = fallbackErrors.length > 0 ? fallbackErrors.join(" | ") : (lastResponse.error || "All services failed");
|
|
||||||
await MarkDownloadItemFailed(itemID, finalError);
|
|
||||||
}
|
|
||||||
return lastResponse;
|
|
||||||
}
|
|
||||||
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
|
|
||||||
let audioFormat: string | undefined;
|
|
||||||
if (service === "tidal") {
|
|
||||||
audioFormat = getTidalAudioFormat(settings, "single");
|
|
||||||
}
|
|
||||||
else if (service === "qobuz") {
|
|
||||||
audioFormat = settings.qobuzQuality || "6";
|
|
||||||
}
|
|
||||||
else if (service === "deezer") {
|
|
||||||
audioFormat = "flac";
|
|
||||||
}
|
|
||||||
logger.debug(`trying ${service} for: ${trackName} - ${artistName}`);
|
|
||||||
const singleServiceResponse = await downloadTrack({
|
|
||||||
service: service as "tidal" | "qobuz" | "amazon",
|
|
||||||
query,
|
|
||||||
track_name: trackName,
|
|
||||||
artist_name: displayArtist,
|
|
||||||
album_name: albumName,
|
|
||||||
album_artist: displayAlbumArtist,
|
|
||||||
release_date: finalReleaseDate || releaseDate,
|
|
||||||
cover_url: coverUrl,
|
|
||||||
output_dir: outputDir,
|
|
||||||
filename_format: settings.filenameTemplate,
|
|
||||||
track_number: settings.trackNumber,
|
|
||||||
position: trackNumberForTemplate,
|
|
||||||
use_album_track_number: useAlbumTrackNumber,
|
|
||||||
spotify_id: spotifyId,
|
|
||||||
embed_lyrics: settings.embedLyrics,
|
|
||||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
|
||||||
duration: durationSecondsForFallback,
|
|
||||||
item_id: itemID,
|
|
||||||
audio_format: audioFormat,
|
|
||||||
tidal_api_url: service === "tidal" ? customTidalApi : undefined,
|
|
||||||
spotify_track_number: spotifyTrackNumber,
|
|
||||||
spotify_disc_number: spotifyDiscNumber,
|
|
||||||
spotify_total_tracks: spotifyTotalTracks,
|
|
||||||
spotify_total_discs: spotifyTotalDiscs,
|
|
||||||
isrc: resolvedTemplateISRC || undefined,
|
|
||||||
copyright: copyright,
|
|
||||||
publisher: publisher,
|
|
||||||
use_first_artist_only: settings.useFirstArtistOnly,
|
|
||||||
use_single_genre: settings.useSingleGenre,
|
|
||||||
embed_genre: settings.embedGenre,
|
|
||||||
});
|
|
||||||
if (!singleServiceResponse.success && itemID) {
|
|
||||||
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
|
||||||
await MarkDownloadItemFailed(itemID, singleServiceResponse.error || "Download failed");
|
|
||||||
}
|
|
||||||
return singleServiceResponse;
|
|
||||||
};
|
|
||||||
const downloadWithItemID = async (settings: any, itemID: string, trackName?: string, artistName?: string, albumName?: string, folderName?: string, position?: number, spotifyId?: string, durationMs?: number, isAlbum?: boolean, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
|
|
||||||
const service = settings.downloader;
|
|
||||||
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
|
|
||||||
const os = settings.operatingSystem;
|
|
||||||
let outputDir = settings.downloadPath;
|
|
||||||
let useAlbumTrackNumber = false;
|
|
||||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
|
||||||
let finalReleaseDate = releaseDate;
|
|
||||||
let finalTrackNumber = spotifyTrackNumber || 0;
|
|
||||||
if (spotifyId) {
|
|
||||||
try {
|
|
||||||
const trackURL = `https://open.spotify.com/track/${spotifyId}`;
|
|
||||||
const trackMetadata = await fetchSpotifyMetadata(trackURL, false, 0, 10);
|
|
||||||
if ("track" in trackMetadata && trackMetadata.track) {
|
|
||||||
if (trackMetadata.track.release_date) {
|
|
||||||
finalReleaseDate = trackMetadata.track.release_date;
|
|
||||||
}
|
|
||||||
if (trackMetadata.track.track_number > 0) {
|
|
||||||
finalTrackNumber = trackMetadata.track.track_number;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const yearValue = releaseYear || finalReleaseDate?.substring(0, 4);
|
|
||||||
const hasSubfolder = settings.folderTemplate && settings.folderTemplate.trim() !== "";
|
|
||||||
const trackNumberForTemplate = (hasSubfolder && finalTrackNumber > 0) ? finalTrackNumber : (position || 0);
|
|
||||||
const displayArtist = settings.useFirstArtistOnly && artistName
|
|
||||||
? getFirstArtist(artistName)
|
|
||||||
: artistName;
|
|
||||||
const displayAlbumArtist = settings.useFirstArtistOnly && albumArtist
|
|
||||||
? getFirstArtist(albumArtist)
|
|
||||||
: albumArtist;
|
|
||||||
const resolvedTemplateISRC = await resolveTemplateISRC(settings, spotifyId);
|
|
||||||
const templateData: TemplateData = {
|
|
||||||
artist: displayArtist?.replace(/\//g, placeholder),
|
|
||||||
album: albumName?.replace(/\//g, placeholder),
|
|
||||||
album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
|
|
||||||
title: trackName?.replace(/\//g, placeholder),
|
|
||||||
isrc: resolvedTemplateISRC?.replace(/\//g, placeholder),
|
|
||||||
track: trackNumberForTemplate,
|
|
||||||
year: yearValue,
|
|
||||||
date: releaseDate,
|
|
||||||
playlist: folderName?.replace(/\//g, placeholder),
|
|
||||||
};
|
|
||||||
const folderTemplate = settings.folderTemplate || "";
|
|
||||||
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
|
||||||
if (settings.createPlaylistFolder && folderName && (!isAlbum || !useAlbumSubfolder)) {
|
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
|
|
||||||
}
|
|
||||||
if (settings.folderTemplate) {
|
|
||||||
const folderPath = parseTemplate(settings.folderTemplate, templateData);
|
|
||||||
if (folderPath) {
|
|
||||||
const parts = folderPath.split("/").filter(p => p.trim());
|
|
||||||
for (const part of parts) {
|
|
||||||
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
|
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (service === "auto") {
|
|
||||||
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
|
||||||
let streamingURLs: any = null;
|
|
||||||
if (spotifyId && shouldFetchStreamingURLs(order)) {
|
|
||||||
try {
|
|
||||||
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
|
|
||||||
const urlsJson = await GetStreamingURLs(spotifyId, region);
|
|
||||||
streamingURLs = JSON.parse(urlsJson);
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
console.error("Failed to get streaming URLs:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
|
||||||
let lastResponse: any = { success: false, error: "No matching services found" };
|
|
||||||
const fallbackErrors: string[] = [];
|
|
||||||
const tidalQuality = getTidalAudioFormat(settings, "auto");
|
|
||||||
const is24Bit = (settings.autoQuality || "24") === "24";
|
|
||||||
const qobuzQuality = is24Bit ? "27" : "6";
|
|
||||||
for (const s of order) {
|
|
||||||
if (s === "tidal" && streamingURLs?.tidal_url) {
|
|
||||||
try {
|
|
||||||
logger.debug(`trying Tidal for: ${trackName} - ${artistName}`);
|
|
||||||
const response = await downloadTrack({
|
|
||||||
service: "tidal",
|
|
||||||
query,
|
|
||||||
track_name: trackName,
|
|
||||||
artist_name: displayArtist,
|
|
||||||
album_name: albumName,
|
|
||||||
album_artist: displayAlbumArtist,
|
|
||||||
release_date: finalReleaseDate || releaseDate,
|
|
||||||
cover_url: coverUrl,
|
|
||||||
output_dir: outputDir,
|
|
||||||
filename_format: settings.filenameTemplate,
|
|
||||||
track_number: settings.trackNumber,
|
|
||||||
position,
|
|
||||||
use_album_track_number: useAlbumTrackNumber,
|
|
||||||
spotify_id: spotifyId,
|
|
||||||
embed_lyrics: settings.embedLyrics,
|
|
||||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
|
||||||
service_url: streamingURLs?.tidal_url,
|
|
||||||
duration: durationSeconds,
|
|
||||||
item_id: itemID,
|
|
||||||
audio_format: tidalQuality,
|
|
||||||
spotify_track_number: spotifyTrackNumber,
|
|
||||||
spotify_disc_number: spotifyDiscNumber,
|
|
||||||
spotify_total_tracks: spotifyTotalTracks,
|
|
||||||
spotify_total_discs: spotifyTotalDiscs,
|
|
||||||
isrc: resolvedTemplateISRC || undefined,
|
|
||||||
copyright: copyright,
|
|
||||||
publisher: publisher,
|
|
||||||
use_first_artist_only: settings.useFirstArtistOnly,
|
|
||||||
use_single_genre: settings.useSingleGenre,
|
|
||||||
embed_genre: settings.embedGenre,
|
|
||||||
});
|
|
||||||
if (response.success) {
|
|
||||||
logger.success(`Tidal: ${trackName} - ${artistName}`);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
const errMsg = response.error || response.message || "Failed";
|
|
||||||
fallbackErrors.push(`[Tidal] ${errMsg}`);
|
|
||||||
lastResponse = response;
|
|
||||||
logger.warning(`Tidal failed, trying next...`);
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
logger.error(`Tidal error: ${err}`);
|
|
||||||
fallbackErrors.push(`[Tidal] ${String(err)}`);
|
|
||||||
lastResponse = { success: false, error: String(err) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (s === "amazon" && streamingURLs?.amazon_url) {
|
else if (s === "amazon" && streamingURLs?.amazon_url) {
|
||||||
try {
|
try {
|
||||||
logger.debug(`trying amazon for: ${trackName} - ${artistName}`);
|
logger.debug(`trying amazon for: ${trackName} - ${artistName}`);
|
||||||
@@ -617,6 +633,7 @@ export function useDownload(region: string) {
|
|||||||
duration: durationSeconds,
|
duration: durationSeconds,
|
||||||
item_id: itemID,
|
item_id: itemID,
|
||||||
audio_format: qobuzQuality,
|
audio_format: qobuzQuality,
|
||||||
|
qobuz_api_url: customQobuzApi,
|
||||||
spotify_track_number: spotifyTrackNumber,
|
spotify_track_number: spotifyTrackNumber,
|
||||||
spotify_disc_number: spotifyDiscNumber,
|
spotify_disc_number: spotifyDiscNumber,
|
||||||
spotify_total_tracks: spotifyTotalTracks,
|
spotify_total_tracks: spotifyTotalTracks,
|
||||||
@@ -659,6 +676,9 @@ export function useDownload(region: string) {
|
|||||||
else if (service === "qobuz") {
|
else if (service === "qobuz") {
|
||||||
audioFormat = settings.qobuzQuality || "6";
|
audioFormat = settings.qobuzQuality || "6";
|
||||||
}
|
}
|
||||||
|
else if (service === "amazon") {
|
||||||
|
audioFormat = settings.amazonQuality || "16";
|
||||||
|
}
|
||||||
const singleServiceResponse = await downloadTrack({
|
const singleServiceResponse = await downloadTrack({
|
||||||
service: service as "tidal" | "qobuz" | "amazon",
|
service: service as "tidal" | "qobuz" | "amazon",
|
||||||
query,
|
query,
|
||||||
@@ -679,6 +699,8 @@ export function useDownload(region: string) {
|
|||||||
duration: durationSecondsForFallback,
|
duration: durationSecondsForFallback,
|
||||||
item_id: itemID,
|
item_id: itemID,
|
||||||
audio_format: audioFormat,
|
audio_format: audioFormat,
|
||||||
|
tidal_api_url: service === "tidal" ? customTidalApi : undefined,
|
||||||
|
qobuz_api_url: service === "qobuz" ? customQobuzApi : undefined,
|
||||||
spotify_track_number: spotifyTrackNumber,
|
spotify_track_number: spotifyTrackNumber,
|
||||||
spotify_disc_number: spotifyDiscNumber,
|
spotify_disc_number: spotifyDiscNumber,
|
||||||
spotify_total_tracks: spotifyTotalTracks,
|
spotify_total_tracks: spotifyTotalTracks,
|
||||||
@@ -832,6 +854,10 @@ export function useDownload(region: string) {
|
|||||||
try {
|
try {
|
||||||
const releaseYear = track.release_date?.substring(0, 4);
|
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);
|
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.success) {
|
||||||
if (response.already_exists) {
|
if (response.already_exists) {
|
||||||
skippedCount++;
|
skippedCount++;
|
||||||
@@ -1005,6 +1031,10 @@ export function useDownload(region: string) {
|
|||||||
try {
|
try {
|
||||||
const releaseYear = track.release_date?.substring(0, 4);
|
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);
|
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.success) {
|
||||||
if (response.already_exists) {
|
if (response.already_exists) {
|
||||||
skippedCount++;
|
skippedCount++;
|
||||||
@@ -1083,6 +1113,15 @@ export function useDownload(region: string) {
|
|||||||
const handleStopDownload = () => {
|
const handleStopDownload = () => {
|
||||||
logger.info("download stopped by user");
|
logger.info("download stopped by user");
|
||||||
shouldStopDownloadRef.current = true;
|
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...");
|
toast.info("Stopping download...");
|
||||||
};
|
};
|
||||||
const resetDownloadedTracks = () => {
|
const resetDownloadedTracks = () => {
|
||||||
|
|||||||
@@ -4,12 +4,16 @@ export interface DownloadProgressInfo {
|
|||||||
is_downloading: boolean;
|
is_downloading: boolean;
|
||||||
mb_downloaded: number;
|
mb_downloaded: number;
|
||||||
speed_mbps: number;
|
speed_mbps: number;
|
||||||
|
rate_limited?: boolean;
|
||||||
|
rate_limit_secs?: number;
|
||||||
}
|
}
|
||||||
export function useDownloadProgress() {
|
export function useDownloadProgress() {
|
||||||
const [progress, setProgress] = useState<DownloadProgressInfo>({
|
const [progress, setProgress] = useState<DownloadProgressInfo>({
|
||||||
is_downloading: false,
|
is_downloading: false,
|
||||||
mb_downloaded: 0,
|
mb_downloaded: 0,
|
||||||
speed_mbps: 0,
|
speed_mbps: 0,
|
||||||
|
rate_limited: false,
|
||||||
|
rate_limit_secs: 0,
|
||||||
});
|
});
|
||||||
const intervalRef = useRef<number | null>(null);
|
const intervalRef = useRef<number | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ export function useMetadata() {
|
|||||||
info: info,
|
info: info,
|
||||||
image: image,
|
image: image,
|
||||||
data: jsonStr,
|
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)
|
timestamp: Math.floor(Date.now() / 1000)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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 {
|
@theme inline {
|
||||||
--font-mono: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
--font-mono: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
}
|
}
|
||||||
|
|||||||
+113
-47
@@ -1,4 +1,3 @@
|
|||||||
import { CheckAPIStatus } from "../../wailsjs/go/main/App";
|
|
||||||
import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout";
|
import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout";
|
||||||
export type ApiCheckStatus = "checking" | "online" | "offline" | "idle";
|
export type ApiCheckStatus = "checking" | "online" | "offline" | "idle";
|
||||||
export interface ApiSource {
|
export interface ApiSource {
|
||||||
@@ -18,18 +17,19 @@ export const API_SOURCES: ApiSource[] = [
|
|||||||
{ id: "tidal", type: "tidal", name: "Tidal", url: "" },
|
{ id: "tidal", type: "tidal", name: "Tidal", url: "" },
|
||||||
{ id: "qobuz", type: "qobuz", name: "Qobuz", url: "" },
|
{ id: "qobuz", type: "qobuz", name: "Qobuz", url: "" },
|
||||||
{ id: "amazon", type: "amazon", name: "Amazon Music", url: "" },
|
{ id: "amazon", type: "amazon", name: "Amazon Music", url: "" },
|
||||||
{ id: "musicbrainz", type: "musicbrainz", name: "MusicBrainz", url: "https://musicbrainz.org" },
|
|
||||||
];
|
];
|
||||||
export const SPOTIFLAC_NEXT_SOURCES: SpotiFLACNextSource[] = [
|
export const SPOTIFLAC_NEXT_SOURCES: SpotiFLACNextSource[] = [
|
||||||
{ id: "tidal", name: "Tidal", statusKey: "tidal" },
|
{ id: "tidal", name: "Tidal", statusPrefix: "tidal_" },
|
||||||
{ id: "qobuz", name: "Qobuz", statusPrefix: "qobuz_" },
|
{ id: "qobuz", name: "Qobuz", statusPrefix: "qobuz_" },
|
||||||
{ id: "amazon", name: "Amazon Music", statusPrefix: "amazon_" },
|
{ id: "amazon", name: "Amazon Music", statusPrefix: "amazon_" },
|
||||||
{ id: "deezer", name: "Deezer", statusPrefix: "deezer_" },
|
{ id: "deezer", name: "Deezer", statusPrefix: "deezer_" },
|
||||||
{ id: "apple", name: "Apple Music", statusKey: "apple" },
|
{ id: "apple", name: "Apple Music", statusKey: "apple" },
|
||||||
];
|
];
|
||||||
const SPOTIFLAC_NEXT_STATUS_URL = "https://gist.githubusercontent.com/afkarxyz/6e57cd362cbd67f889e3a91a76254a5e/raw";
|
const SPOTIFLAC_STATUS_URL = "https://gist.githubusercontent.com/afkarxyz/6e57cd362cbd67f889e3a91a76254a5e/raw";
|
||||||
const SPOTIFLAC_NEXT_MAX_ATTEMPTS = 3;
|
const SPOTIFLAC_CURRENT_STATUS_URL = "https://gist.githubusercontent.com/afkarxyz/7e392bc94ec2faaf74ef7d80025636eb/raw";
|
||||||
const SPOTIFLAC_NEXT_RETRY_DELAY_MS = 1200;
|
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);
|
||||||
type ApiStatusState = {
|
type ApiStatusState = {
|
||||||
checkingSources: Record<string, boolean>;
|
checkingSources: Record<string, boolean>;
|
||||||
statuses: Record<string, ApiCheckStatus>;
|
statuses: Record<string, ApiCheckStatus>;
|
||||||
@@ -40,7 +40,10 @@ let apiStatusState: ApiStatusState = {
|
|||||||
statuses: {},
|
statuses: {},
|
||||||
nextStatuses: {},
|
nextStatuses: {},
|
||||||
};
|
};
|
||||||
|
let activeCheckCurrentOnly: Promise<void> | null = null;
|
||||||
let activeCheckNextOnly: Promise<void> | null = null;
|
let activeCheckNextOnly: Promise<void> | null = null;
|
||||||
|
let activeStatusPayloadFetch: Promise<SpotiFLACNextStatusResponse> | null = null;
|
||||||
|
let activeCurrentStatusPayloadFetch: Promise<SpotiFLACNextStatusResponse> | null = null;
|
||||||
const activeSourceChecks = new Map<string, Promise<void>>();
|
const activeSourceChecks = new Map<string, Promise<void>>();
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
function emitApiStatusChange() {
|
function emitApiStatusChange() {
|
||||||
@@ -52,15 +55,20 @@ function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState)
|
|||||||
apiStatusState = updater(apiStatusState);
|
apiStatusState = updater(apiStatusState);
|
||||||
emitApiStatusChange();
|
emitApiStatusChange();
|
||||||
}
|
}
|
||||||
async function checkSourceStatus(source: ApiSource): Promise<ApiCheckStatus> {
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
function sendStatusConsole(level: "info" | "warning" | "error", message: string): void {
|
||||||
try {
|
try {
|
||||||
const isOnline = await withTimeout(CheckAPIStatus(source.type, source.url), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.name}`);
|
void LogStatusConsole(level, message);
|
||||||
return isOnline ? "online" : "offline";
|
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
return "offline";
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function logStatusError(message: string): void {
|
||||||
|
sendStatusConsole("error", message);
|
||||||
|
}
|
||||||
function anyNextVariantUp(values: Array<string | undefined>): ApiCheckStatus {
|
function anyNextVariantUp(values: Array<string | undefined>): ApiCheckStatus {
|
||||||
return values.some((value) => value === "up") ? "online" : "offline";
|
return values.some((value) => value === "up") ? "online" : "offline";
|
||||||
}
|
}
|
||||||
@@ -80,9 +88,6 @@ function getNextSourceValues(payload: SpotiFLACNextStatusResponse, source: Spoti
|
|||||||
}
|
}
|
||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
function delay(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
function getSafeNextStatusesFallback(currentStatuses: Record<string, ApiCheckStatus>): Record<string, ApiCheckStatus> {
|
function getSafeNextStatusesFallback(currentStatuses: Record<string, ApiCheckStatus>): Record<string, ApiCheckStatus> {
|
||||||
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
|
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
|
||||||
const current = currentStatuses[source.id];
|
const current = currentStatuses[source.id];
|
||||||
@@ -90,38 +95,87 @@ function getSafeNextStatusesFallback(currentStatuses: Record<string, ApiCheckSta
|
|||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
}
|
}
|
||||||
async function fetchSpotiFLACNextStatusesOnce(): Promise<Record<string, ApiCheckStatus>> {
|
function hasCurrentResults(): boolean {
|
||||||
const response = await withTimeout(fetch(SPOTIFLAC_NEXT_STATUS_URL, {
|
return API_SOURCES.some((source) => {
|
||||||
|
const status = apiStatusState.statuses[source.id];
|
||||||
|
return status === "online" || status === "offline";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function hasSpotiFLACNextResults(): boolean {
|
||||||
|
return SPOTIFLAC_NEXT_SOURCES.some((source) => {
|
||||||
|
const status = apiStatusState.nextStatuses[source.id];
|
||||||
|
return status === "online" || status === "offline";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function fetchStatusPayloadOnce(url: string): Promise<SpotiFLACNextStatusResponse> {
|
||||||
|
const response = await withTimeout(fetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
},
|
},
|
||||||
}), CHECK_TIMEOUT_MS, "SpotiFLAC Next status check timed out after 10 seconds");
|
}), CHECK_TIMEOUT_MS, "SpotiFLAC status check timed out after 10 seconds");
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`SpotiFLAC Next status returned ${response.status}`);
|
throw new Error(`SpotiFLAC status returned ${response.status}`);
|
||||||
}
|
}
|
||||||
const payload = (await response.json()) as SpotiFLACNextStatusResponse;
|
return (await response.json()) as SpotiFLACNextStatusResponse;
|
||||||
|
}
|
||||||
|
async function fetchStatusPayloadWithRetry(url: string): Promise<SpotiFLACNextStatusResponse> {
|
||||||
|
let lastError: unknown = null;
|
||||||
|
for (let attempt = 1; attempt <= SPOTIFLAC_STATUS_MAX_ATTEMPTS; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fetchStatusPayloadOnce(url);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (attempt < SPOTIFLAC_STATUS_MAX_ATTEMPTS) {
|
||||||
|
await delay(SPOTIFLAC_STATUS_RETRY_DELAY_MS * attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError instanceof Error ? lastError : new Error("SpotiFLAC status check failed");
|
||||||
|
}
|
||||||
|
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) => {
|
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
|
||||||
acc[source.id] = anyNextVariantUp(getNextSourceValues(payload, source));
|
acc[source.id] = anyNextVariantUp(getNextSourceValues(payload, source));
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
}
|
}
|
||||||
async function checkSpotiFLACNextStatuses(): Promise<Record<string, ApiCheckStatus>> {
|
|
||||||
let lastError: unknown = null;
|
|
||||||
for (let attempt = 1; attempt <= SPOTIFLAC_NEXT_MAX_ATTEMPTS; attempt++) {
|
|
||||||
try {
|
|
||||||
return await fetchSpotiFLACNextStatusesOnce();
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
lastError = error;
|
|
||||||
if (attempt < SPOTIFLAC_NEXT_MAX_ATTEMPTS) {
|
|
||||||
await delay(SPOTIFLAC_NEXT_RETRY_DELAY_MS * attempt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw lastError instanceof Error ? lastError : new Error("SpotiFLAC Next status check failed");
|
|
||||||
}
|
|
||||||
export function getApiStatusState(): ApiStatusState {
|
export function getApiStatusState(): ApiStatusState {
|
||||||
return apiStatusState;
|
return apiStatusState;
|
||||||
}
|
}
|
||||||
@@ -131,11 +185,19 @@ export function subscribeApiStatus(listener: () => void): () => void {
|
|||||||
listeners.delete(listener);
|
listeners.delete(listener);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function hasSpotiFLACNextResults(): boolean {
|
export async function checkCurrentApiStatusesOnly(): Promise<void> {
|
||||||
return SPOTIFLAC_NEXT_SOURCES.some((source) => {
|
if (activeCheckCurrentOnly) {
|
||||||
const status = apiStatusState.nextStatuses[source.id];
|
return activeCheckCurrentOnly;
|
||||||
return status === "online" || status === "offline";
|
}
|
||||||
});
|
activeCheckCurrentOnly = (async () => {
|
||||||
|
await Promise.all(API_SOURCES.map((source) => checkApiStatus(source.id)));
|
||||||
|
})();
|
||||||
|
try {
|
||||||
|
await activeCheckCurrentOnly;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
activeCheckCurrentOnly = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
|
export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
|
||||||
if (activeCheckNextOnly) {
|
if (activeCheckNextOnly) {
|
||||||
@@ -151,10 +213,6 @@ export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
setApiStatusState((current) => ({
|
|
||||||
...current,
|
|
||||||
nextStatuses: { ...current.nextStatuses },
|
|
||||||
}));
|
|
||||||
const nextStatuses = await checkSpotiFLACNextStatuses();
|
const nextStatuses = await checkSpotiFLACNextStatuses();
|
||||||
setApiStatusState((current) => ({
|
setApiStatusState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -170,17 +228,25 @@ export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
|
|||||||
nextStatuses: getSafeNextStatusesFallback(current.nextStatuses),
|
nextStatuses: getSafeNextStatusesFallback(current.nextStatuses),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
finally {
|
|
||||||
activeCheckNextOnly = null;
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
return activeCheckNextOnly;
|
try {
|
||||||
|
await activeCheckNextOnly;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
activeCheckNextOnly = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
export function ensureSpotiFLACNextStatusCheckStarted(): void {
|
export function ensureApiStatusCheckStarted(): void {
|
||||||
|
if (!activeCheckCurrentOnly && !hasCurrentResults()) {
|
||||||
|
void checkCurrentApiStatusesOnly();
|
||||||
|
}
|
||||||
if (!activeCheckNextOnly && !hasSpotiFLACNextResults()) {
|
if (!activeCheckNextOnly && !hasSpotiFLACNextResults()) {
|
||||||
void checkSpotiFLACNextStatusesOnly();
|
void checkSpotiFLACNextStatusesOnly();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export function ensureSpotiFLACNextStatusCheckStarted(): void {
|
||||||
|
ensureApiStatusCheckStarted();
|
||||||
|
}
|
||||||
export async function checkApiStatus(sourceId: string): Promise<void> {
|
export async function checkApiStatus(sourceId: string): Promise<void> {
|
||||||
const source = API_SOURCES.find((item) => item.id === sourceId);
|
const source = API_SOURCES.find((item) => item.id === sourceId);
|
||||||
if (!source) {
|
if (!source) {
|
||||||
|
|||||||
@@ -40,3 +40,6 @@ export function buildClickableArtists(artists: string, artistsData?: ArtistSimpl
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
export function getClickableArtistKey(artist: ClickableArtist) {
|
||||||
|
return artist.id || artist.external_urls || artist.name;
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface Settings {
|
|||||||
downloadPath: string;
|
downloadPath: string;
|
||||||
downloader: "auto" | "tidal" | "qobuz" | "amazon";
|
downloader: "auto" | "tidal" | "qobuz" | "amazon";
|
||||||
customTidalApi: string;
|
customTidalApi: string;
|
||||||
|
customQobuzApi: string;
|
||||||
linkResolver: "songstats" | "songlink";
|
linkResolver: "songstats" | "songlink";
|
||||||
allowResolverFallback: boolean;
|
allowResolverFallback: boolean;
|
||||||
theme: string;
|
theme: string;
|
||||||
@@ -41,7 +42,7 @@ export interface Settings {
|
|||||||
operatingSystem: "Windows" | "linux/MacOS";
|
operatingSystem: "Windows" | "linux/MacOS";
|
||||||
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
|
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
|
||||||
qobuzQuality: "6" | "7" | "27";
|
qobuzQuality: "6" | "7" | "27";
|
||||||
amazonQuality: "original";
|
amazonQuality: "16" | "24";
|
||||||
autoOrder: "tidal-qobuz-amazon" | "tidal-amazon-qobuz" | "qobuz-tidal-amazon" | "qobuz-amazon-tidal" | "amazon-tidal-qobuz" | "amazon-qobuz-tidal" | string;
|
autoOrder: "tidal-qobuz-amazon" | "tidal-amazon-qobuz" | "qobuz-tidal-amazon" | "qobuz-amazon-tidal" | "amazon-tidal-qobuz" | "amazon-qobuz-tidal" | string;
|
||||||
autoQuality: "16" | "24";
|
autoQuality: "16" | "24";
|
||||||
allowFallback: boolean;
|
allowFallback: boolean;
|
||||||
@@ -167,6 +168,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
downloadPath: "",
|
downloadPath: "",
|
||||||
downloader: "auto",
|
downloader: "auto",
|
||||||
customTidalApi: "",
|
customTidalApi: "",
|
||||||
|
customQobuzApi: "",
|
||||||
linkResolver: "songlink",
|
linkResolver: "songlink",
|
||||||
allowResolverFallback: true,
|
allowResolverFallback: true,
|
||||||
theme: "yellow",
|
theme: "yellow",
|
||||||
@@ -184,7 +186,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
operatingSystem: detectOS(),
|
operatingSystem: detectOS(),
|
||||||
tidalQuality: "LOSSLESS",
|
tidalQuality: "LOSSLESS",
|
||||||
qobuzQuality: "6",
|
qobuzQuality: "6",
|
||||||
amazonQuality: "original",
|
amazonQuality: "16",
|
||||||
autoOrder: "tidal-qobuz-amazon",
|
autoOrder: "tidal-qobuz-amazon",
|
||||||
autoQuality: "16",
|
autoQuality: "16",
|
||||||
allowFallback: true,
|
allowFallback: true,
|
||||||
@@ -521,6 +523,36 @@ function normalizeCustomTidalApi(value: unknown): string {
|
|||||||
? value.trim().replace(/\/+$/g, "")
|
? 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 {
|
function normalizeExistingFileCheckMode(mode: unknown): ExistingFileCheckMode {
|
||||||
switch (typeof mode === "string" ? mode.trim().toLowerCase() : "") {
|
switch (typeof mode === "string" ? mode.trim().toLowerCase() : "") {
|
||||||
case "isrc":
|
case "isrc":
|
||||||
@@ -580,15 +612,21 @@ function normalizeSettingsPayload(settings: SettingsPayload): SettingsPayload {
|
|||||||
normalized.qobuzQuality = "6";
|
normalized.qobuzQuality = "6";
|
||||||
}
|
}
|
||||||
if (!("amazonQuality" in normalized)) {
|
if (!("amazonQuality" in normalized)) {
|
||||||
normalized.amazonQuality = "original";
|
normalized.amazonQuality = "16";
|
||||||
|
}
|
||||||
|
if (normalized.amazonQuality !== "16" && normalized.amazonQuality !== "24") {
|
||||||
|
normalized.amazonQuality = "16";
|
||||||
}
|
}
|
||||||
if (!("autoOrder" in normalized)) {
|
if (!("autoOrder" in normalized)) {
|
||||||
normalized.autoOrder = "tidal-qobuz-amazon";
|
normalized.autoOrder = DEFAULT_SETTINGS.autoOrder;
|
||||||
}
|
}
|
||||||
if (!("autoQuality" in normalized)) {
|
if (!("autoQuality" in normalized)) {
|
||||||
normalized.autoQuality = "16";
|
normalized.autoQuality = "16";
|
||||||
}
|
}
|
||||||
normalized.customTidalApi = normalizeCustomTidalApi(normalized.customTidalApi);
|
normalized.customTidalApi = normalizeCustomTidalApi(normalized.customTidalApi);
|
||||||
|
normalized.customQobuzApi = normalizeCustomQobuzApi(normalized.customQobuzApi);
|
||||||
|
normalized.downloader = normalizeDownloader(normalized.downloader);
|
||||||
|
normalized.autoOrder = sanitizeAutoOrder(normalized.autoOrder);
|
||||||
if (!("allowFallback" in normalized)) {
|
if (!("allowFallback" in normalized)) {
|
||||||
normalized.allowFallback = true;
|
normalized.allowFallback = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { MotionConfig } from "motion/react";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
createRoot(document.getElementById("root")!).render(<StrictMode>
|
createRoot(document.getElementById("root")!).render(<StrictMode>
|
||||||
<App />
|
<MotionConfig reducedMotion="user">
|
||||||
<Toaster position="bottom-left" duration={1000}/>
|
<App />
|
||||||
|
<Toaster position="bottom-left" duration={1000}/>
|
||||||
|
</MotionConfig>
|
||||||
</StrictMode>);
|
</StrictMode>);
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export interface AlbumInfo {
|
|||||||
release_date: string;
|
release_date: string;
|
||||||
artists: string;
|
artists: string;
|
||||||
images: string;
|
images: string;
|
||||||
|
is_explicit?: boolean;
|
||||||
upc?: string;
|
upc?: string;
|
||||||
batch?: string;
|
batch?: string;
|
||||||
}
|
}
|
||||||
@@ -93,6 +94,7 @@ export interface DiscographyAlbum {
|
|||||||
artists: string;
|
artists: string;
|
||||||
images: string;
|
images: string;
|
||||||
external_urls: string;
|
external_urls: string;
|
||||||
|
is_explicit?: boolean;
|
||||||
}
|
}
|
||||||
export interface ArtistDiscographyResponse {
|
export interface ArtistDiscographyResponse {
|
||||||
artist_info: ArtistInfo;
|
artist_info: ArtistInfo;
|
||||||
@@ -120,6 +122,7 @@ export interface DownloadRequest {
|
|||||||
release_date?: string;
|
release_date?: string;
|
||||||
cover_url?: string;
|
cover_url?: string;
|
||||||
tidal_api_url?: string;
|
tidal_api_url?: string;
|
||||||
|
qobuz_api_url?: string;
|
||||||
output_dir?: string;
|
output_dir?: string;
|
||||||
audio_format?: string;
|
audio_format?: string;
|
||||||
folder_name?: string;
|
folder_name?: string;
|
||||||
@@ -151,6 +154,7 @@ export interface DownloadResponse {
|
|||||||
file?: string;
|
file?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
already_exists?: boolean;
|
already_exists?: boolean;
|
||||||
|
cancelled?: boolean;
|
||||||
item_id?: string;
|
item_id?: string;
|
||||||
}
|
}
|
||||||
export interface HealthResponse {
|
export interface HealthResponse {
|
||||||
|
|||||||
@@ -3,19 +3,21 @@ module github.com/afkarxyz/SpotiFLAC
|
|||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/Eyevinn/mp4ff v0.52.0
|
||||||
github.com/bogem/id3v2/v2 v2.1.4
|
github.com/bogem/id3v2/v2 v2.1.4
|
||||||
github.com/go-flac/flacpicture v0.3.0
|
github.com/go-flac/flacpicture v0.3.0
|
||||||
github.com/go-flac/flacvorbis v0.2.0
|
github.com/go-flac/flacvorbis v0.2.0
|
||||||
github.com/go-flac/go-flac v1.0.0
|
github.com/go-flac/go-flac v1.0.0
|
||||||
github.com/pquerna/otp v1.5.0
|
github.com/pquerna/otp v1.5.0
|
||||||
github.com/ulikunitz/xz v0.5.15
|
github.com/ulikunitz/xz v0.5.15
|
||||||
github.com/wailsapp/wails/v2 v2.11.0
|
github.com/wailsapp/wails/v2 v2.12.0
|
||||||
go.etcd.io/bbolt v1.4.3
|
go.etcd.io/bbolt v1.4.3
|
||||||
golang.org/x/image v0.12.0
|
golang.org/x/image v0.12.0
|
||||||
golang.org/x/text v0.31.0
|
golang.org/x/text v0.31.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||||
github.com/bep/debounce v1.2.1 // indirect
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
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 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
|
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
|
||||||
@@ -15,6 +19,8 @@ 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-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 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
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 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8=
|
||||||
github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
@@ -73,8 +79,8 @@ github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+
|
|||||||
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||||
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
|
||||||
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"productName": "SpotiFLAC",
|
"productName": "SpotiFLAC",
|
||||||
"productVersion": "7.1.6",
|
"productVersion": "7.1.8",
|
||||||
"copyright": "© 2026 afkarxyz"
|
"copyright": "© 2026 afkarxyz"
|
||||||
},
|
},
|
||||||
"wailsjsdir": "./frontend",
|
"wailsjsdir": "./frontend",
|
||||||
|
|||||||
Reference in New Issue
Block a user