Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8da9aed4b | |||
| 954cfe9d4f | |||
| 31e9ecac35 | |||
| 0c3a7b70af | |||
| 254022d81d | |||
| b3ebef5ab9 | |||
| 0093df6016 | |||
| 30cbcf8ab1 | |||
| 7346730be9 | |||
| 59a057b14a | |||
| 2bc2c0bf03 | |||
| f13359df7f |
+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.
|
||||
|
||||
## 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 for Android & iOS — maintained by [@zarzet](https://github.com/zarzet)
|
||||
|
||||
### [SpotiFLAC (CLI)](https://github.com/Nizarberyan/SpotiFLAC)
|
||||
|
||||
SpotiFLAC for command-line environments — maintained by [@Nizarberyan](https://github.com/Nizarberyan)
|
||||
|
||||
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
||||
|
||||
SpotiFLAC Python library for SpotiFLAC integration — maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu)
|
||||
@@ -108,7 +110,7 @@ The software is provided "as is", without warranty of any kind. The author assum
|
||||
|
||||
## API Credits
|
||||
|
||||
[MusicBrainz](https://musicbrainz.org) · [LRCLIB](https://lrclib.net) · [Songlink/Odesli](https://song.link) · [hifi-api](https://github.com/binimum/hifi-api) · [dabmusic.xyz](https://dabmusic.xyz)
|
||||
[MusicBrainz](https://musicbrainz.org) · [LRCLIB](https://lrclib.net) · [Songlink/Odesli](https://song.link) · [Songstats](https://songstats.com) · [hifi-api](https://github.com/binimum/hifi-api) · [Qobuz-DL](https://github.com/QobuzDL/Qobuz-DL)
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
|
||||
@@ -33,12 +33,41 @@ type CurrentIPInfo struct {
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
type APIStatusTargetResult struct {
|
||||
Target string `json:"target"`
|
||||
Label string `json:"label"`
|
||||
Online bool `json:"online"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type APIStatusReport struct {
|
||||
Type string `json:"type"`
|
||||
Online bool `json:"online"`
|
||||
RequireAll bool `json:"require_all"`
|
||||
Details []APIStatusTargetResult `json:"details"`
|
||||
}
|
||||
|
||||
const checkOperationTimeout = 10 * time.Second
|
||||
|
||||
func NewApp() *App {
|
||||
return &App{}
|
||||
}
|
||||
|
||||
func (a *App) LogStatusConsole(level string, message string) {
|
||||
normalizedLevel := strings.ToLower(strings.TrimSpace(level))
|
||||
if normalizedLevel == "" {
|
||||
normalizedLevel = "info"
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("[%s] [%s] %s\n", time.Now().Format("15:04:05"), normalizedLevel, strings.TrimSpace(message))
|
||||
switch normalizedLevel {
|
||||
case "error":
|
||||
_, _ = fmt.Fprint(os.Stderr, line)
|
||||
default:
|
||||
fmt.Print(line)
|
||||
}
|
||||
}
|
||||
|
||||
type timedResult[T any] struct {
|
||||
value T
|
||||
err error
|
||||
@@ -276,11 +305,12 @@ func (a *App) startup(ctx context.Context) {
|
||||
if err := backend.InitProviderPriorityDB(); err != nil {
|
||||
fmt.Printf("Failed to init provider priority DB: %v\n", err)
|
||||
}
|
||||
go func() {
|
||||
if err := backend.PrimeTidalAPIList(); err != nil {
|
||||
fmt.Printf("Failed to prime Tidal API list: %v\n", err)
|
||||
}
|
||||
}()
|
||||
if err := backend.CleanupLegacyTidalPublicAPIState(); err != nil {
|
||||
fmt.Printf("Failed to clean legacy Tidal API cache: %v\n", err)
|
||||
}
|
||||
if err := backend.SanitizePersistedConfigSettings(); err != nil {
|
||||
fmt.Printf("Failed to sanitize persisted config settings: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) shutdown(ctx context.Context) {
|
||||
@@ -307,7 +337,7 @@ type DownloadRequest struct {
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
TidalAPIURL string `json:"tidal_api_url,omitempty"`
|
||||
TidalVariant string `json:"tidal_variant,omitempty"`
|
||||
QobuzAPIURL string `json:"qobuz_api_url,omitempty"`
|
||||
OutputDir string `json:"output_dir,omitempty"`
|
||||
AudioFormat string `json:"audio_format,omitempty"`
|
||||
FilenameFormat string `json:"filename_format,omitempty"`
|
||||
@@ -343,6 +373,7 @@ type DownloadResponse struct {
|
||||
File string `json:"file,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
Cancelled bool `json:"cancelled,omitempty"`
|
||||
ItemID string `json:"item_id,omitempty"`
|
||||
}
|
||||
|
||||
@@ -508,7 +539,8 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
if req.FilenameFormat == "" {
|
||||
req.FilenameFormat = "title-artist"
|
||||
}
|
||||
if req.ISRC == "" && strings.Contains(req.FilenameFormat, "{isrc}") && req.SpotifyID != "" {
|
||||
shouldResolveISRC := strings.Contains(req.FilenameFormat, "{isrc}") || backend.GetExistingFileCheckModeSetting() == "isrc"
|
||||
if req.ISRC == "" && shouldResolveISRC && req.SpotifyID != "" {
|
||||
req.ISRC = backend.ResolveTrackISRC(req.SpotifyID)
|
||||
}
|
||||
|
||||
@@ -528,6 +560,20 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
backend.StartDownloadItem(itemID)
|
||||
defer backend.SetDownloading(false)
|
||||
|
||||
_, finishDownloadScope := backend.BeginDownloadCancellationScope()
|
||||
defer finishDownloadScope()
|
||||
|
||||
if err := backend.CheckDownloadCancelled(); err != nil {
|
||||
backend.SkipDownloadItem(itemID, "")
|
||||
return DownloadResponse{
|
||||
Success: false,
|
||||
Message: "Download cancelled",
|
||||
Error: "Download cancelled",
|
||||
ItemID: itemID,
|
||||
Cancelled: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
spotifyURL := ""
|
||||
if req.SpotifyID != "" {
|
||||
spotifyURL = fmt.Sprintf("https://open.spotify.com/track/%s", req.SpotifyID)
|
||||
@@ -662,24 +708,11 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
}
|
||||
|
||||
case "tidal":
|
||||
tidalVariant := strings.ToLower(strings.TrimSpace(req.TidalVariant))
|
||||
if tidalVariant == "alt" {
|
||||
downloader := backend.NewTidalDownloader("")
|
||||
filename, err = downloader.DownloadAlt(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||
} else if req.TidalAPIURL == "" || req.TidalAPIURL == "auto" {
|
||||
downloader := backend.NewTidalDownloader("")
|
||||
if req.ServiceURL != "" {
|
||||
filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||
} else {
|
||||
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||
}
|
||||
downloader := backend.NewTidalDownloader(req.TidalAPIURL)
|
||||
if req.ServiceURL != "" {
|
||||
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||
} else {
|
||||
downloader := backend.NewTidalDownloader(req.TidalAPIURL)
|
||||
if req.ServiceURL != "" {
|
||||
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||
} else {
|
||||
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||
}
|
||||
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||
}
|
||||
|
||||
case "qobuz":
|
||||
@@ -690,6 +723,9 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
isrc = <-isrcChan
|
||||
}
|
||||
downloader := backend.NewQobuzDownloader()
|
||||
if strings.HasPrefix(strings.TrimRight(strings.TrimSpace(req.QobuzAPIURL), "/"), "https://") {
|
||||
downloader.SetCustomAPIURL(req.QobuzAPIURL)
|
||||
}
|
||||
quality := req.AudioFormat
|
||||
if quality == "" {
|
||||
quality = "6"
|
||||
@@ -704,6 +740,22 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if backend.IsDownloadCancelledError(err) {
|
||||
if filename != "" && !strings.HasPrefix(filename, "EXISTS:") {
|
||||
if _, statErr := os.Stat(filename); statErr == nil {
|
||||
os.Remove(filename)
|
||||
}
|
||||
}
|
||||
backend.SkipDownloadItem(itemID, "")
|
||||
return DownloadResponse{
|
||||
Success: false,
|
||||
Message: "Download cancelled",
|
||||
Error: "Download cancelled",
|
||||
ItemID: itemID,
|
||||
Cancelled: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
backend.FailDownloadItem(itemID, fmt.Sprintf("Download failed: %v", err))
|
||||
|
||||
if filename != "" && !strings.HasPrefix(filename, "EXISTS:") {
|
||||
@@ -729,6 +781,20 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
filename = strings.TrimPrefix(filename, "EXISTS:")
|
||||
}
|
||||
|
||||
if !alreadyExists {
|
||||
if err := backend.CheckDownloadCancelled(); err != nil {
|
||||
cleanupInvalidDownloadArtifacts(filename)
|
||||
backend.SkipDownloadItem(itemID, "")
|
||||
return DownloadResponse{
|
||||
Success: false,
|
||||
Message: "Download cancelled",
|
||||
Error: "Download cancelled",
|
||||
ItemID: itemID,
|
||||
Cancelled: true,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if !alreadyExists {
|
||||
validated, validationErr := backend.ValidateDownloadedTrackDuration(filename, req.Duration)
|
||||
if validationErr != nil {
|
||||
@@ -795,9 +861,6 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
}
|
||||
|
||||
historySource := req.Service
|
||||
if req.Service == "tidal" && strings.EqualFold(strings.TrimSpace(req.TidalVariant), "alt") {
|
||||
historySource = "tidal alt"
|
||||
}
|
||||
|
||||
go func(fPath, track, artist, album, sID, cover, format, source string) {
|
||||
time.Sleep(2 * time.Second)
|
||||
@@ -826,21 +889,21 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
DurationStr: durationStr,
|
||||
CoverURL: cover,
|
||||
Quality: quality,
|
||||
Format: strings.ToUpper(format),
|
||||
Path: fPath,
|
||||
Source: source,
|
||||
}
|
||||
|
||||
if item.Format == "" || item.Format == "LOSSLESS" {
|
||||
ext := filepath.Ext(fPath)
|
||||
if len(ext) > 1 {
|
||||
item.Format = strings.ToUpper(ext[1:])
|
||||
}
|
||||
item.Format = strings.ToUpper(strings.TrimSpace(format))
|
||||
|
||||
if ext := filepath.Ext(fPath); len(ext) > 1 {
|
||||
item.Format = strings.ToUpper(ext[1:])
|
||||
}
|
||||
|
||||
switch item.Format {
|
||||
case "6", "7", "27":
|
||||
case "6", "7", "27", "LOSSLESS", "HI_RES", "HI_RES_LOSSLESS":
|
||||
item.Format = "FLAC"
|
||||
case "ALAC", "APPLE", "ATMOS", "M4A-AAC", "M4A-ALAC":
|
||||
item.Format = "M4A"
|
||||
}
|
||||
|
||||
backend.AddHistoryItem(item, "SpotiFLAC")
|
||||
@@ -921,6 +984,10 @@ func (a *App) CancelAllQueuedItems() {
|
||||
backend.CancelAllQueuedItems()
|
||||
}
|
||||
|
||||
func (a *App) ForceStopDownloads() {
|
||||
backend.ForceStopActiveDownloads()
|
||||
}
|
||||
|
||||
func (a *App) ExportFailedDownloads() (string, error) {
|
||||
queueInfo := backend.GetDownloadQueue()
|
||||
var failedItems []string
|
||||
@@ -993,15 +1060,7 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
|
||||
isOnline, err := runWithTimeout(checkOperationTimeout, func() (bool, error) {
|
||||
switch apiType {
|
||||
case "tidal":
|
||||
if checkGroupedAPIStatus("tidal", buildTidalStatusCheckURLs(apiURL)) {
|
||||
return true, nil
|
||||
}
|
||||
if strings.TrimSpace(apiURL) == "" {
|
||||
if _, refreshErr := backend.RefreshTidalAPIList(true); refreshErr == nil && checkGroupedAPIStatus("tidal", buildTidalStatusCheckURLs("")) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
return checkGroupedAPIStatus("tidal", buildTidalStatusCheckURLs(apiURL)), nil
|
||||
case "qobuz", "qbz":
|
||||
return checkGroupedAPIStatus("qobuz", buildQobuzStatusCheckURLs(apiURL)), nil
|
||||
case "amazon":
|
||||
@@ -1029,48 +1088,191 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
|
||||
return isOnline
|
||||
}
|
||||
|
||||
func (a *App) CheckAPIStatusReport(apiType string, apiURL string) APIStatusReport {
|
||||
report, err := runWithTimeout(checkOperationTimeout, func() (APIStatusReport, error) {
|
||||
switch apiType {
|
||||
case "tidal":
|
||||
return buildGroupedAPIStatusReport("tidal", buildTidalStatusCheckURLs(apiURL), false), nil
|
||||
case "qobuz", "qbz":
|
||||
return buildGroupedAPIStatusReport("qobuz", buildQobuzStatusCheckURLs(apiURL), false), nil
|
||||
case "amazon":
|
||||
return buildGroupedAPIStatusReport("amazon", buildAmazonStatusCheckURLs(apiURL), false), nil
|
||||
case "lrclib":
|
||||
return buildGroupedAPIStatusReport("lrclib", buildLRCLIBStatusCheckURLs(apiURL), false), nil
|
||||
case "musicbrainz":
|
||||
return buildGroupedAPIStatusReport("musicbrainz", buildMusicBrainzStatusCheckURLs(apiURL), false), nil
|
||||
default:
|
||||
return buildGroupedAPIStatusReport(apiType, []string{strings.TrimSpace(apiURL)}, false), nil
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return APIStatusReport{
|
||||
Type: apiType,
|
||||
Online: false,
|
||||
RequireAll: apiType == "qobuz" || apiType == "qbz",
|
||||
Details: []APIStatusTargetResult{{
|
||||
Target: strings.TrimSpace(apiURL),
|
||||
Label: describeAPIStatusTarget(apiType, apiURL),
|
||||
Online: false,
|
||||
Message: err.Error(),
|
||||
}},
|
||||
}
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func (a *App) CheckCustomTidalAPI(apiURL string) bool {
|
||||
type tidalProbeResponse struct {
|
||||
Version string `json:"version"`
|
||||
Data struct {
|
||||
TrackID int64 `json:"trackId"`
|
||||
AssetPresentation string `json:"assetPresentation"`
|
||||
ManifestMimeType string `json:"manifestMimeType"`
|
||||
Manifest string `json:"manifest"`
|
||||
} `json:"data"`
|
||||
}
|
||||
type tidalLegacyResponse struct {
|
||||
OriginalTrackURL string `json:"OriginalTrackUrl"`
|
||||
}
|
||||
|
||||
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||
if apiURL == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
const probeTrackID int64 = 441821360
|
||||
probeURL := fmt.Sprintf("%s/track/?id=%d&quality=LOSSLESS", apiURL, probeTrackID)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, probeURL, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("[CheckCustomTidalAPI] Failed to create request for %s: %v\n", apiURL, err)
|
||||
return false
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 12 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("[CheckCustomTidalAPI] Probe request failed for %s: %v\n", apiURL, err)
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
if err != nil {
|
||||
fmt.Printf("[CheckCustomTidalAPI] Failed to read probe response for %s: %v\n", apiURL, err)
|
||||
return false
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
fmt.Printf("[CheckCustomTidalAPI] Probe returned status %d for %s: %s\n", resp.StatusCode, apiURL, previewResponseBody(body, 200))
|
||||
return false
|
||||
}
|
||||
|
||||
var probe tidalProbeResponse
|
||||
if err := json.Unmarshal(body, &probe); err == nil {
|
||||
assetPresentation := strings.ToUpper(strings.TrimSpace(probe.Data.AssetPresentation))
|
||||
switch assetPresentation {
|
||||
case "FULL":
|
||||
if strings.TrimSpace(probe.Data.Manifest) != "" {
|
||||
fmt.Printf("[CheckCustomTidalAPI] Tidal API is ONLINE for %s (assetPresentation=%s)\n", apiURL, assetPresentation)
|
||||
return true
|
||||
}
|
||||
fmt.Printf("[CheckCustomTidalAPI] Probe returned FULL without manifest for %s\n", apiURL)
|
||||
return false
|
||||
case "PREVIEW":
|
||||
fmt.Printf("[CheckCustomTidalAPI] Probe returned PREVIEW for %s\n", apiURL)
|
||||
return false
|
||||
case "":
|
||||
|
||||
default:
|
||||
fmt.Printf("[CheckCustomTidalAPI] Probe returned unsupported assetPresentation=%s for %s\n", assetPresentation, apiURL)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var legacy []tidalLegacyResponse
|
||||
if err := json.Unmarshal(body, &legacy); err == nil {
|
||||
for _, item := range legacy {
|
||||
if strings.TrimSpace(item.OriginalTrackURL) != "" {
|
||||
fmt.Printf("[CheckCustomTidalAPI] Tidal API is ONLINE for %s (legacy response)\n", apiURL)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("[CheckCustomTidalAPI] Probe response was unusable for %s: %s\n", apiURL, previewResponseBody(body, 200))
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) CheckCustomQobuzAPI(apiURL string) bool {
|
||||
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||
if !strings.HasPrefix(apiURL, "https://") {
|
||||
return false
|
||||
}
|
||||
|
||||
const probeTrackID int64 = 64868955
|
||||
probeURL := fmt.Sprintf("%s/api/download-music?track_id=%d&quality=27", apiURL, probeTrackID)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, probeURL, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("[CheckCustomQobuzAPI] Failed to create request for %s: %v\n", apiURL, err)
|
||||
return false
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("[CheckCustomQobuzAPI] Probe request failed for %s: %v\n", apiURL, err)
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
if err != nil {
|
||||
fmt.Printf("[CheckCustomQobuzAPI] Failed to read probe response for %s: %v\n", apiURL, err)
|
||||
return false
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
fmt.Printf("[CheckCustomQobuzAPI] Probe returned status %d for %s: %s\n", resp.StatusCode, apiURL, previewResponseBody(body, 200))
|
||||
return false
|
||||
}
|
||||
|
||||
var probe struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &probe); err != nil {
|
||||
fmt.Printf("[CheckCustomQobuzAPI] Failed to decode probe response for %s: %v\n", apiURL, err)
|
||||
return false
|
||||
}
|
||||
if probe.Success && strings.TrimSpace(probe.Data.URL) != "" {
|
||||
fmt.Printf("[CheckCustomQobuzAPI] Qobuz instance is ONLINE for %s\n", apiURL)
|
||||
return true
|
||||
}
|
||||
|
||||
fmt.Printf("[CheckCustomQobuzAPI] Probe response was unusable for %s: %s\n", apiURL, previewResponseBody(body, 200))
|
||||
return false
|
||||
}
|
||||
|
||||
func buildTidalStatusCheckURLs(apiURL string) []string {
|
||||
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||
if apiURL != "" {
|
||||
return []string{fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL)}
|
||||
if apiURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
apis, err := backend.GetRotatedTidalAPIList()
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to load rotated Tidal API list for status check: %v\n", err)
|
||||
}
|
||||
|
||||
urls := make([]string, 0, len(apis))
|
||||
for _, baseURL := range apis {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" {
|
||||
continue
|
||||
}
|
||||
urls = append(urls, fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", baseURL))
|
||||
}
|
||||
|
||||
return urls
|
||||
return []string{fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL)}
|
||||
}
|
||||
|
||||
func buildQobuzStatusCheckURLs(apiURL string) []string {
|
||||
if trimmed := strings.TrimSpace(apiURL); trimmed != "" {
|
||||
return []string{buildQobuzStatusCheckURL(trimmed)}
|
||||
return []string{trimmed}
|
||||
}
|
||||
|
||||
bases := backend.GetQobuzStreamAPIBaseURLs()
|
||||
urls := make([]string, 0, len(bases))
|
||||
for _, baseURL := range bases {
|
||||
urls = append(urls, buildQobuzStatusCheckURL(baseURL))
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
func buildQobuzStatusCheckURL(apiBase string) string {
|
||||
apiBase = strings.TrimSpace(apiBase)
|
||||
if strings.Contains(apiBase, "qobuz.spotbye.qzz.io") {
|
||||
return fmt.Sprintf("%s360735657?quality=27", apiBase)
|
||||
}
|
||||
return fmt.Sprintf("%s360735657&quality=27", apiBase)
|
||||
return backend.GetQobuzDownloadProviderURLs()
|
||||
}
|
||||
|
||||
func buildAmazonStatusCheckURLs(apiURL string) []string {
|
||||
@@ -1136,8 +1338,185 @@ func checkGroupedAPIStatus(apiType string, checkURLs []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func buildGroupedAPIStatusReport(apiType string, checkURLs []string, requireAll bool) APIStatusReport {
|
||||
filtered := make([]string, 0, len(checkURLs))
|
||||
for _, rawURL := range checkURLs {
|
||||
target := strings.TrimSpace(rawURL)
|
||||
if target == "" {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, target)
|
||||
}
|
||||
|
||||
report := APIStatusReport{
|
||||
Type: apiType,
|
||||
Online: !requireAll,
|
||||
RequireAll: requireAll,
|
||||
Details: make([]APIStatusTargetResult, len(filtered)),
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
report.Online = false
|
||||
return report
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for index, target := range filtered {
|
||||
wg.Add(1)
|
||||
go func(idx int, rawTarget string) {
|
||||
defer wg.Done()
|
||||
report.Details[idx] = checkSingleAPIStatusDetailed(apiType, rawTarget)
|
||||
}(index, target)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if requireAll {
|
||||
report.Online = true
|
||||
for _, detail := range report.Details {
|
||||
if !detail.Online {
|
||||
report.Online = false
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
report.Online = false
|
||||
for _, detail := range report.Details {
|
||||
if detail.Online {
|
||||
report.Online = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
func describeAPIStatusTarget(apiType string, checkURL string) string {
|
||||
trimmedType := strings.TrimSpace(strings.ToLower(apiType))
|
||||
trimmedURL := strings.TrimSpace(checkURL)
|
||||
|
||||
if trimmedType == "qobuz" || trimmedType == "qbz" {
|
||||
switch {
|
||||
case backend.IsQobuzWJHEProviderURL(trimmedURL):
|
||||
return "WJHE"
|
||||
case backend.IsQobuzMusicDLProviderURL(trimmedURL):
|
||||
return "MusicDL"
|
||||
case backend.IsQobuzGDStudioProviderURL(trimmedURL):
|
||||
parsed, err := url.Parse(trimmedURL)
|
||||
if err == nil {
|
||||
host := strings.ToLower(strings.TrimSpace(parsed.Host))
|
||||
switch {
|
||||
case strings.Contains(host, "xyz"):
|
||||
return "GDStudio XYZ"
|
||||
case strings.Contains(host, "org"):
|
||||
return "GDStudio ORG"
|
||||
}
|
||||
}
|
||||
return "GDStudio"
|
||||
}
|
||||
}
|
||||
|
||||
if trimmedURL != "" {
|
||||
if parsed, err := url.Parse(trimmedURL); err == nil && strings.TrimSpace(parsed.Host) != "" {
|
||||
return strings.TrimSpace(parsed.Host)
|
||||
}
|
||||
}
|
||||
|
||||
if trimmedType == "" {
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
return strings.ToUpper(trimmedType)
|
||||
}
|
||||
|
||||
func checkSingleAPIStatusDetailed(apiType string, checkURL string) APIStatusTargetResult {
|
||||
result := APIStatusTargetResult{
|
||||
Target: strings.TrimSpace(checkURL),
|
||||
Label: describeAPIStatusTarget(apiType, checkURL),
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 4 * time.Second}
|
||||
trimmedType := strings.TrimSpace(strings.ToLower(apiType))
|
||||
|
||||
if trimmedType == "qobuz" || trimmedType == "qbz" {
|
||||
var err error
|
||||
switch {
|
||||
case backend.IsQobuzWJHEProviderURL(checkURL):
|
||||
err = backend.CheckQobuzWJHEStatusDetailed(client)
|
||||
case backend.IsQobuzMusicDLProviderURL(checkURL):
|
||||
err = backend.CheckQobuzMusicDLStatusDetailed(client)
|
||||
case backend.IsQobuzGDStudioProviderURL(checkURL):
|
||||
err = backend.CheckQobuzGDStudioAPIStatusDetailed(client, checkURL)
|
||||
default:
|
||||
err = fmt.Errorf("unknown qobuz provider url: %s", strings.TrimSpace(checkURL))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
result.Message = err.Error()
|
||||
return result
|
||||
}
|
||||
|
||||
result.Online = true
|
||||
result.Message = "stream URL resolved"
|
||||
return result
|
||||
}
|
||||
|
||||
req, err := backend.NewRequestWithDefaultHeaders(http.MethodGet, checkURL, nil)
|
||||
if err != nil {
|
||||
result.Message = fmt.Sprintf("failed to create request: %v", err)
|
||||
return result
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
result.Message = fmt.Sprintf("request failed: %v", err)
|
||||
return result
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
if err != nil {
|
||||
result.Message = fmt.Sprintf("failed to read response: %v", err)
|
||||
return result
|
||||
}
|
||||
|
||||
switch trimmedType {
|
||||
case "amazon":
|
||||
if resp.StatusCode == http.StatusOK && strings.Contains(string(body), `"amazonMusic":"up"`) {
|
||||
result.Online = true
|
||||
result.Message = `amazonMusic="up"`
|
||||
return result
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
result.Message = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, previewResponseBody(body, 160))
|
||||
return result
|
||||
}
|
||||
result.Message = `amazonMusic was not reported as "up"`
|
||||
return result
|
||||
default:
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
result.Online = true
|
||||
result.Message = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
return result
|
||||
}
|
||||
result.Message = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, previewResponseBody(body, 160))
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
func checkSingleAPIStatus(apiType string, checkURL string) bool {
|
||||
client := &http.Client{Timeout: 4 * time.Second}
|
||||
if apiType == "qobuz" || apiType == "qbz" {
|
||||
switch {
|
||||
case backend.IsQobuzWJHEProviderURL(checkURL):
|
||||
return backend.CheckQobuzWJHEStatus(client)
|
||||
case backend.IsQobuzMusicDLProviderURL(checkURL):
|
||||
return backend.CheckQobuzMusicDLStatus(client)
|
||||
case backend.IsQobuzGDStudioProviderURL(checkURL):
|
||||
return backend.CheckQobuzGDStudioAPIStatus(client, checkURL)
|
||||
}
|
||||
}
|
||||
|
||||
req, err := backend.NewRequestWithDefaultHeaders(http.MethodGet, checkURL, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
@@ -1638,6 +2017,28 @@ func (a *App) ReadFileMetadata(filePath string) (*backend.AudioMetadata, error)
|
||||
return backend.ReadAudioMetadata(filePath)
|
||||
}
|
||||
|
||||
func (a *App) ReadEmbeddedLyrics(filePath string) (*backend.EmbeddedLyrics, error) {
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required")
|
||||
}
|
||||
return backend.ReadEmbeddedLyrics(filePath)
|
||||
}
|
||||
|
||||
func (a *App) ExtractLyricsToLRC(filePath string, overwrite bool) (*backend.ExtractLyricsResult, error) {
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required")
|
||||
}
|
||||
return backend.ExtractLyricsToLRC(filePath, overwrite)
|
||||
}
|
||||
|
||||
func (a *App) SelectLyricsFiles() ([]string, error) {
|
||||
files, err := backend.SelectLyricsFiles(a.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (a *App) PreviewRenameFiles(files []string, format string) []backend.RenamePreview {
|
||||
return backend.PreviewRename(files, format)
|
||||
}
|
||||
@@ -1733,6 +2134,68 @@ type CheckFileExistenceResult struct {
|
||||
ArtistName string `json:"artist_name,omitempty"`
|
||||
}
|
||||
|
||||
type existingFileLookupIndex struct {
|
||||
byFilename map[string]string
|
||||
byISRC map[string]string
|
||||
}
|
||||
|
||||
func isAudioFileForExistenceCheck(path string) bool {
|
||||
switch strings.ToLower(filepath.Ext(path)) {
|
||||
case ".flac", ".mp3", ".m4a":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeExistingFileIdentifier(value string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func buildExistingFileLookupIndex(scanRoot string, mode string) existingFileLookupIndex {
|
||||
index := existingFileLookupIndex{
|
||||
byFilename: make(map[string]string),
|
||||
byISRC: make(map[string]string),
|
||||
}
|
||||
|
||||
scanRoot = backend.NormalizePath(scanRoot)
|
||||
if scanRoot == "" {
|
||||
return index
|
||||
}
|
||||
|
||||
_ = filepath.Walk(scanRoot, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info == nil || info.IsDir() || !isAudioFileForExistenceCheck(path) {
|
||||
return nil
|
||||
}
|
||||
if info.Size() <= 100*1024 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, exists := index.byFilename[info.Name()]; !exists {
|
||||
index.byFilename[info.Name()] = path
|
||||
}
|
||||
|
||||
if mode == "filename" {
|
||||
return nil
|
||||
}
|
||||
|
||||
metadata, metadataErr := backend.ExtractFullMetadataFromFile(path)
|
||||
if metadataErr != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if normalizedISRC := normalizeExistingFileIdentifier(metadata.ISRC); normalizedISRC != "" {
|
||||
if _, exists := index.byISRC[normalizedISRC]; !exists {
|
||||
index.byISRC[normalizedISRC] = path
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []CheckFileExistenceRequest) []CheckFileExistenceResult {
|
||||
if len(tracks) == 0 {
|
||||
return []CheckFileExistenceResult{}
|
||||
@@ -1745,6 +2208,11 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
|
||||
|
||||
defaultFilenameFormat := "title-artist"
|
||||
redownloadWithSuffix := backend.GetRedownloadWithSuffixSetting()
|
||||
existingFileCheckMode := backend.GetExistingFileCheckModeSetting()
|
||||
scanRoot := outputDir
|
||||
if rootDir != "" {
|
||||
scanRoot = rootDir
|
||||
}
|
||||
|
||||
type result struct {
|
||||
index int
|
||||
@@ -1752,29 +2220,13 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
|
||||
}
|
||||
|
||||
resultsChan := make(chan result, len(tracks))
|
||||
|
||||
var rootDirFiles map[string]string
|
||||
rootDirFilesOnce := false
|
||||
getRootDirFiles := func() map[string]string {
|
||||
if rootDirFilesOnce {
|
||||
return rootDirFiles
|
||||
}
|
||||
rootDirFiles = make(map[string]string)
|
||||
if rootDir != "" && rootDir != outputDir {
|
||||
filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if !info.IsDir() {
|
||||
if strings.EqualFold(filepath.Ext(path), ".flac") || strings.EqualFold(filepath.Ext(path), ".mp3") {
|
||||
rootDirFiles[info.Name()] = path
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
rootDirFilesOnce = true
|
||||
return rootDirFiles
|
||||
var lookupIndex existingFileLookupIndex
|
||||
var lookupIndexOnce sync.Once
|
||||
getLookupIndex := func() existingFileLookupIndex {
|
||||
lookupIndexOnce.Do(func() {
|
||||
lookupIndex = buildExistingFileLookupIndex(scanRoot, existingFileCheckMode)
|
||||
})
|
||||
return lookupIndex
|
||||
}
|
||||
|
||||
for i, track := range tracks {
|
||||
@@ -1796,7 +2248,8 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
|
||||
filenameFormat = defaultFilenameFormat
|
||||
}
|
||||
isrc := strings.TrimSpace(t.ISRC)
|
||||
if isrc == "" && strings.Contains(filenameFormat, "{isrc}") && t.SpotifyID != "" {
|
||||
shouldResolveISRC := existingFileCheckMode == "isrc" || strings.Contains(filenameFormat, "{isrc}")
|
||||
if isrc == "" && shouldResolveISRC && t.SpotifyID != "" {
|
||||
isrc = backend.ResolveTrackISRC(t.SpotifyID)
|
||||
}
|
||||
|
||||
@@ -1806,8 +2259,11 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
|
||||
}
|
||||
|
||||
fileExt := ".flac"
|
||||
if t.AudioFormat == "mp3" {
|
||||
switch strings.ToLower(strings.TrimSpace(t.AudioFormat)) {
|
||||
case "mp3":
|
||||
fileExt = ".mp3"
|
||||
case "m4a", "m4a-aac", "m4a-alac", "alac", "atmos", "apple":
|
||||
fileExt = ".m4a"
|
||||
}
|
||||
|
||||
expectedFilenameBase := backend.BuildExpectedFilename(
|
||||
@@ -1836,14 +2292,29 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
|
||||
expectedPath := filepath.Join(targetDir, expectedFilename)
|
||||
if redownloadWithSuffix {
|
||||
expectedPath, _ = backend.ResolveOutputPathForDownload(expectedPath, true)
|
||||
res.FilePath = filepath.Base(expectedPath)
|
||||
} else {
|
||||
resultsChan <- result{index: idx, result: res}
|
||||
return
|
||||
}
|
||||
|
||||
normalizedISRC := normalizeExistingFileIdentifier(isrc)
|
||||
effectiveMode := existingFileCheckMode
|
||||
if effectiveMode == "isrc" && normalizedISRC == "" {
|
||||
effectiveMode = "filename"
|
||||
}
|
||||
|
||||
switch effectiveMode {
|
||||
case "isrc":
|
||||
if path, ok := getLookupIndex().byISRC[normalizedISRC]; ok {
|
||||
res.Exists = true
|
||||
res.FilePath = path
|
||||
}
|
||||
default:
|
||||
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
|
||||
res.Exists = true
|
||||
res.FilePath = expectedPath
|
||||
} else {
|
||||
|
||||
res.FilePath = expectedFilename
|
||||
} else if path, ok := getLookupIndex().byFilename[filepath.Base(expectedPath)]; ok {
|
||||
res.Exists = true
|
||||
res.FilePath = path
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1852,39 +2323,10 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
|
||||
}
|
||||
|
||||
results := make([]CheckFileExistenceResult, len(tracks))
|
||||
missingIndices := []int{}
|
||||
|
||||
for i := 0; i < len(tracks); i++ {
|
||||
r := <-resultsChan
|
||||
results[r.index] = r.result
|
||||
if !results[r.index].Exists {
|
||||
missingIndices = append(missingIndices, r.index)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missingIndices) > 0 && rootDir != "" {
|
||||
filesMap := getRootDirFiles()
|
||||
if len(filesMap) > 0 {
|
||||
for _, idx := range missingIndices {
|
||||
|
||||
expectedFilename := results[idx].FilePath
|
||||
baseName := filepath.Base(expectedFilename)
|
||||
if path, ok := filesMap[baseName]; ok {
|
||||
results[idx].Exists = true
|
||||
results[idx].FilePath = path
|
||||
} else {
|
||||
results[idx].FilePath = ""
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, idx := range missingIndices {
|
||||
results[idx].FilePath = ""
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, idx := range missingIndices {
|
||||
results[idx].FilePath = ""
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
@@ -1910,11 +2352,20 @@ func (a *App) GetConfigPath() (string, error) {
|
||||
return filepath.Join(dir, "config.json"), nil
|
||||
}
|
||||
|
||||
func (a *App) GetFontsPath() (string, error) {
|
||||
dir, err := backend.GetFFmpegDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "fonts.json"), nil
|
||||
}
|
||||
|
||||
func (a *App) SaveSettings(settings map[string]interface{}) error {
|
||||
configPath, err := a.GetConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings = backend.SanitizeSettingsMap(settings)
|
||||
|
||||
dir := filepath.Dir(configPath)
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
@@ -1931,6 +2382,27 @@ func (a *App) SaveSettings(settings map[string]interface{}) error {
|
||||
return os.WriteFile(configPath, data, 0644)
|
||||
}
|
||||
|
||||
func (a *App) SaveFonts(fonts []map[string]interface{}) error {
|
||||
fontsPath, err := a.GetFontsPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dir := filepath.Dir(fontsPath)
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(fonts, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(fontsPath, data, 0644)
|
||||
}
|
||||
|
||||
func (a *App) LoadSettings() (map[string]interface{}, error) {
|
||||
configPath, err := a.GetConfigPath()
|
||||
if err != nil {
|
||||
@@ -1951,7 +2423,33 @@ func (a *App) LoadSettings() (map[string]interface{}, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return settings, nil
|
||||
return backend.SanitizeSettingsMap(settings), nil
|
||||
}
|
||||
|
||||
func (a *App) LoadFonts() ([]map[string]interface{}, error) {
|
||||
fontsPath, err := a.GetFontsPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(fontsPath); os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(fontsPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var fonts []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &fonts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if fonts == nil {
|
||||
return []map[string]interface{}{}, nil
|
||||
}
|
||||
|
||||
return fonts, nil
|
||||
}
|
||||
|
||||
func (a *App) CheckFFmpegInstalled() (bool, error) {
|
||||
|
||||
+130
-100
@@ -1,6 +1,7 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -18,11 +19,6 @@ type AmazonDownloader struct {
|
||||
regions []string
|
||||
}
|
||||
|
||||
type AmazonStreamResponse struct {
|
||||
StreamURL string `json:"streamUrl"`
|
||||
DecryptionKey string `json:"decryptionKey"`
|
||||
}
|
||||
|
||||
func NewAmazonDownloader() *AmazonDownloader {
|
||||
return &AmazonDownloader{
|
||||
client: &http.Client{
|
||||
@@ -48,7 +44,29 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
|
||||
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})`)
|
||||
asin := asinRegex.FindString(amazonURL)
|
||||
@@ -56,14 +74,28 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
|
||||
return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL)
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("%s/api/track/%s", amazonMusicAPIBaseURL, asin)
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
|
||||
payload, err := json.Marshal(map[string]string{
|
||||
"id": asin,
|
||||
"quality": amazonCommunityNormalizeQuality(quality),
|
||||
"country": "US",
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
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 {
|
||||
return "", err
|
||||
}
|
||||
@@ -78,29 +110,43 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
|
||||
return "", err
|
||||
}
|
||||
|
||||
var apiResp AmazonStreamResponse
|
||||
var apiResp amazonCommunityResponse
|
||||
if err := json.Unmarshal(bodyBytes, &apiResp); err != nil {
|
||||
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")
|
||||
}
|
||||
|
||||
downloadURL := apiResp.StreamURL
|
||||
fileName := fmt.Sprintf("%s.m4a", asin)
|
||||
filePath := filepath.Join(outputDir, fileName)
|
||||
keySpecs := apiResp.KeySpecs
|
||||
if len(keySpecs) == 0 {
|
||||
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 {
|
||||
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 {
|
||||
return "", err
|
||||
}
|
||||
if captcha := strings.TrimSpace(apiResp.Captcha); captcha != "" {
|
||||
dlReq.Header.Set("x-captcha-token", captcha)
|
||||
}
|
||||
|
||||
dlResp, err := a.client.Do(dlReq)
|
||||
if err != nil {
|
||||
@@ -108,101 +154,85 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
|
||||
}
|
||||
defer dlResp.Body.Close()
|
||||
|
||||
fmt.Printf("Downloading track: %s\n", fileName)
|
||||
fmt.Printf("Downloading track: %s\n", asin)
|
||||
pw := NewProgressWriter(out)
|
||||
_, err = io.Copy(pw, dlResp.Body)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(filePath)
|
||||
if _, err = io.Copy(pw, dlResp.Body); err != nil {
|
||||
return "", err
|
||||
}
|
||||
out.Close()
|
||||
|
||||
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")
|
||||
|
||||
ffprobePath, err := GetFFprobePath()
|
||||
var codec string
|
||||
if err == nil {
|
||||
cmdProbe := exec.Command(ffprobePath,
|
||||
"-v", "quiet",
|
||||
"-select_streams", "a:0",
|
||||
"-show_entries", "stream=codec_name",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
filePath,
|
||||
)
|
||||
setHideWindow(cmdProbe)
|
||||
codecOutput, _ := cmdProbe.Output()
|
||||
codec = strings.TrimSpace(string(codecOutput))
|
||||
fmt.Printf("Detected codec: %s\n", codec)
|
||||
decryptedPath := filepath.Join(outputDir, fmt.Sprintf("%s.decrypted.mp4", asin))
|
||||
if err := decryptWithMP4FF(keySpecs, encryptedPath, decryptedPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
targetExt := ".m4a"
|
||||
if codec == "flac" {
|
||||
targetExt = ".flac"
|
||||
}
|
||||
|
||||
decryptedFilename := "dec_" + fileName + targetExt
|
||||
|
||||
if targetExt == ".flac" && strings.HasSuffix(fileName, ".m4a") {
|
||||
decryptedFilename = "dec_" + strings.TrimSuffix(fileName, ".m4a") + ".flac"
|
||||
}
|
||||
|
||||
decryptedPath := filepath.Join(outputDir, decryptedFilename)
|
||||
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ffmpeg not found for decryption: %w", err)
|
||||
}
|
||||
|
||||
if err := ValidateExecutable(ffmpegPath); err != nil {
|
||||
return "", fmt.Errorf("invalid ffmpeg executable: %w", err)
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(apiResp.DecryptionKey)
|
||||
|
||||
cmd := exec.Command(ffmpegPath,
|
||||
"-decryption_key", key,
|
||||
"-i", filePath,
|
||||
"-c", "copy",
|
||||
"-y",
|
||||
decryptedPath,
|
||||
)
|
||||
|
||||
setHideWindow(cmd)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
|
||||
outStr := string(output)
|
||||
if len(outStr) > 500 {
|
||||
outStr = outStr[len(outStr)-500:]
|
||||
}
|
||||
return "", fmt.Errorf("ffmpeg decryption failed: %v\nTail Output: %s", err, outStr)
|
||||
}
|
||||
|
||||
if info, err := os.Stat(decryptedPath); err != nil || info.Size() == 0 {
|
||||
return "", fmt.Errorf("decrypted file missing or empty")
|
||||
}
|
||||
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
fmt.Printf("Warning: Failed to remove encrypted file: %v\n", err)
|
||||
}
|
||||
|
||||
finalPath := filepath.Join(outputDir, strings.TrimPrefix(decryptedFilename, "dec_"))
|
||||
if err := os.Rename(decryptedPath, finalPath); err != nil {
|
||||
return "", fmt.Errorf("failed to rename decrypted file: %w", err)
|
||||
}
|
||||
filePath = finalPath
|
||||
|
||||
defer os.Remove(decryptedPath)
|
||||
remuxInput = decryptedPath
|
||||
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) {
|
||||
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) {
|
||||
@@ -259,7 +289,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
|
||||
fmt.Println("Fetching MusicBrainz metadata...")
|
||||
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
|
||||
res.Metadata = fetchedMeta
|
||||
fmt.Println("✓ MusicBrainz metadata fetched")
|
||||
fmt.Println("MusicBrainz metadata fetched")
|
||||
} else {
|
||||
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
||||
}
|
||||
@@ -440,7 +470,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
|
||||
}
|
||||
|
||||
fmt.Println("Done")
|
||||
fmt.Println("✓ Downloaded successfully from Amazon Music")
|
||||
fmt.Println("Downloaded successfully from Amazon Music")
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+156
-1
@@ -2,11 +2,138 @@ package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const legacyTidalAPICacheFile = "tidal-api-urls.json"
|
||||
|
||||
func normalizeCustomTidalAPIValue(value interface{}) string {
|
||||
customAPI, _ := value.(string)
|
||||
customAPI = strings.TrimRight(strings.TrimSpace(customAPI), "/")
|
||||
if strings.HasPrefix(customAPI, "https://") {
|
||||
return customAPI
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sanitizeDownloaderValue(value interface{}, allowTidal bool) string {
|
||||
downloader, _ := value.(string)
|
||||
switch strings.TrimSpace(strings.ToLower(downloader)) {
|
||||
case "tidal":
|
||||
if allowTidal {
|
||||
return "tidal"
|
||||
}
|
||||
return "auto"
|
||||
case "qobuz":
|
||||
return "qobuz"
|
||||
case "amazon":
|
||||
return "amazon"
|
||||
default:
|
||||
return "auto"
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeAutoOrderValue(value interface{}, allowTidal bool) string {
|
||||
autoOrder, _ := value.(string)
|
||||
allowed := map[string]struct{}{
|
||||
"qobuz": {},
|
||||
"amazon": {},
|
||||
}
|
||||
fallback := "qobuz-amazon"
|
||||
if allowTidal {
|
||||
allowed["tidal"] = struct{}{}
|
||||
fallback = "tidal-qobuz-amazon"
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
parts := make([]string, 0, 3)
|
||||
for _, rawPart := range strings.Split(strings.TrimSpace(strings.ToLower(autoOrder)), "-") {
|
||||
part := strings.TrimSpace(rawPart)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[part]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[part]; ok {
|
||||
continue
|
||||
}
|
||||
seen[part] = struct{}{}
|
||||
parts = append(parts, part)
|
||||
}
|
||||
|
||||
if len(parts) < 2 {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return strings.Join(parts, "-")
|
||||
}
|
||||
|
||||
func SanitizeSettingsMap(settings map[string]interface{}) map[string]interface{} {
|
||||
if settings == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
sanitized := make(map[string]interface{}, len(settings))
|
||||
for key, value := range settings {
|
||||
sanitized[key] = value
|
||||
}
|
||||
|
||||
customAPI := normalizeCustomTidalAPIValue(sanitized["customTidalApi"])
|
||||
sanitized["customTidalApi"] = customAPI
|
||||
allowTidal := customAPI != ""
|
||||
sanitized["downloader"] = sanitizeDownloaderValue(sanitized["downloader"], allowTidal)
|
||||
sanitized["autoOrder"] = sanitizeAutoOrderValue(sanitized["autoOrder"], allowTidal)
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func CleanupLegacyTidalPublicAPIState() error {
|
||||
appDir, err := EnsureAppDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cachePath := filepath.Join(appDir, legacyTidalAPICacheFile)
|
||||
if err := os.Remove(cachePath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func SanitizePersistedConfigSettings() error {
|
||||
configPath, err := GetConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var settings map[string]interface{}
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sanitized := SanitizeSettingsMap(settings)
|
||||
payload, err := json.MarshalIndent(sanitized, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(configPath, payload, 0o644)
|
||||
}
|
||||
|
||||
func GetDefaultMusicPath() string {
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
@@ -47,7 +174,7 @@ func LoadConfigSettings() (map[string]interface{}, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return settings, nil
|
||||
return SanitizeSettingsMap(settings), nil
|
||||
}
|
||||
|
||||
func GetRedownloadWithSuffixSetting() bool {
|
||||
@@ -60,6 +187,34 @@ func GetRedownloadWithSuffixSetting() bool {
|
||||
return enabled
|
||||
}
|
||||
|
||||
func GetCustomTidalAPISetting() string {
|
||||
settings, err := LoadConfigSettings()
|
||||
if err != nil || settings == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return normalizeCustomTidalAPIValue(settings["customTidalApi"])
|
||||
}
|
||||
|
||||
func normalizeExistingFileCheckMode(value string) string {
|
||||
switch strings.TrimSpace(strings.ToLower(value)) {
|
||||
case "isrc", "upc":
|
||||
return "isrc"
|
||||
default:
|
||||
return "filename"
|
||||
}
|
||||
}
|
||||
|
||||
func GetExistingFileCheckModeSetting() string {
|
||||
settings, err := LoadConfigSettings()
|
||||
if err != nil || settings == nil {
|
||||
return "filename"
|
||||
}
|
||||
|
||||
rawMode, _ := settings["existingFileCheckMode"].(string)
|
||||
return normalizeExistingFileCheckMode(rawMode)
|
||||
}
|
||||
|
||||
func GetLinkResolverSetting() string {
|
||||
settings, err := LoadConfigSettings()
|
||||
if err != nil || settings == nil {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+228
-53
@@ -11,6 +11,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -19,6 +20,11 @@ import (
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
type executableCandidate struct {
|
||||
path string
|
||||
source string
|
||||
}
|
||||
|
||||
func ValidateExecutable(path string) error {
|
||||
cleanedPath := filepath.Clean(path)
|
||||
if cleanedPath == "" {
|
||||
@@ -83,6 +89,50 @@ func GetFFmpegDir() (string, error) {
|
||||
return EnsureAppDir()
|
||||
}
|
||||
|
||||
func copyExecutable(src, dst string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if _, err = io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := out.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return prepareExecutableForUse(dst)
|
||||
}
|
||||
|
||||
func appendExecutableCandidate(candidates []executableCandidate, seen map[string]struct{}, path, source string) []executableCandidate {
|
||||
cleanedPath := filepath.Clean(strings.TrimSpace(path))
|
||||
if cleanedPath == "" {
|
||||
return candidates
|
||||
}
|
||||
if _, exists := seen[cleanedPath]; exists {
|
||||
return candidates
|
||||
}
|
||||
|
||||
seen[cleanedPath] = struct{}{}
|
||||
return append(candidates, executableCandidate{
|
||||
path: cleanedPath,
|
||||
source: source,
|
||||
})
|
||||
}
|
||||
|
||||
func resolveSystemExecutable(executableName string) string {
|
||||
if runtime.GOOS == "darwin" {
|
||||
candidates := []string{
|
||||
@@ -114,83 +164,163 @@ func resolveSystemExecutable(executableName string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func GetFFmpegPath() (string, error) {
|
||||
ffmpegDir, err := GetFFmpegDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
func runExecutableVersionCheck(path string) error {
|
||||
cmd := exec.Command(path, "-version")
|
||||
setHideWindow(cmd)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func removeMacOSQuarantineAttribute(path string) error {
|
||||
cmd := exec.Command("xattr", "-d", "com.apple.quarantine", path)
|
||||
setHideWindow(cmd)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
trimmedOutput := strings.TrimSpace(string(output))
|
||||
lowerOutput := strings.ToLower(trimmedOutput)
|
||||
if strings.Contains(lowerOutput, "no such xattr") || strings.Contains(lowerOutput, "attribute not found") {
|
||||
return nil
|
||||
}
|
||||
|
||||
if trimmedOutput != "" {
|
||||
return fmt.Errorf("%w: %s", err, trimmedOutput)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func prepareExecutableForUse(path string) error {
|
||||
cleanedPath := filepath.Clean(strings.TrimSpace(path))
|
||||
if cleanedPath == "" {
|
||||
return fmt.Errorf("empty path")
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.Chmod(cleanedPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to mark executable: %w", err)
|
||||
}
|
||||
|
||||
if runtime.GOOS == "darwin" {
|
||||
if err := removeMacOSQuarantineAttribute(cleanedPath); err != nil {
|
||||
fmt.Printf("[FFmpeg] Warning: failed to remove macOS quarantine from %s: %v\n", cleanedPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveExecutablePath(executableName string) (string, string, error) {
|
||||
ffmpegDir, err := GetFFmpegDir()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
localPath := filepath.Join(ffmpegDir, executableName)
|
||||
nextDir := filepath.Join(filepath.Dir(ffmpegDir), ".spotiflac-next")
|
||||
nextPath := filepath.Join(nextDir, executableName)
|
||||
localExists := false
|
||||
candidates := make([]executableCandidate, 0, 3)
|
||||
seen := make(map[string]struct{}, 3)
|
||||
|
||||
if systemPath := resolveSystemExecutable(executableName); systemPath != "" {
|
||||
candidates = appendExecutableCandidate(candidates, seen, systemPath, "system")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(localPath); err == nil {
|
||||
localExists = true
|
||||
candidates = appendExecutableCandidate(candidates, seen, localPath, "local")
|
||||
}
|
||||
|
||||
if !localExists {
|
||||
if _, err := os.Stat(nextPath); err == nil {
|
||||
if copyErr := copyExecutable(nextPath, localPath); copyErr == nil {
|
||||
fmt.Printf("[FFmpeg] Copied %s from SpotiFLAC-Next folder\n", executableName)
|
||||
candidates = appendExecutableCandidate(candidates, seen, localPath, "migrated")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, candidate := range candidates {
|
||||
if candidate.source != "system" {
|
||||
if err := prepareExecutableForUse(candidate.path); err != nil {
|
||||
lastErr = err
|
||||
fmt.Printf("[FFmpeg] Skipping %s %s: %v\n", candidate.source, candidate.path, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := ValidateExecutable(candidate.path); err != nil {
|
||||
lastErr = err
|
||||
fmt.Printf("[FFmpeg] Skipping %s %s: %v\n", candidate.source, candidate.path, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := runExecutableVersionCheck(candidate.path); err != nil {
|
||||
lastErr = err
|
||||
fmt.Printf("[FFmpeg] Skipping %s %s: %v\n", candidate.source, candidate.path, err)
|
||||
continue
|
||||
}
|
||||
|
||||
return candidate.path, localPath, nil
|
||||
}
|
||||
|
||||
if len(candidates) > 0 {
|
||||
if lastErr != nil {
|
||||
return "", localPath, fmt.Errorf("no working %s executable found: %w", executableName, lastErr)
|
||||
}
|
||||
return "", localPath, fmt.Errorf("no working %s executable found", executableName)
|
||||
}
|
||||
|
||||
return "", localPath, fmt.Errorf("%s not found in app directory or system path", executableName)
|
||||
}
|
||||
|
||||
func GetFFmpegPath() (string, error) {
|
||||
ffmpegName := "ffmpeg"
|
||||
if runtime.GOOS == "windows" {
|
||||
ffmpegName = "ffmpeg.exe"
|
||||
}
|
||||
|
||||
if path := resolveSystemExecutable(ffmpegName); path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
localPath := filepath.Join(ffmpegDir, ffmpegName)
|
||||
if _, err := os.Stat(localPath); err == nil {
|
||||
return localPath, nil
|
||||
}
|
||||
|
||||
return localPath, nil
|
||||
}
|
||||
|
||||
func GetFFprobePath() (string, error) {
|
||||
ffmpegDir, err := GetFFmpegDir()
|
||||
path, localPath, err := resolveExecutablePath(ffmpegName)
|
||||
if err != nil {
|
||||
if localPath != "" {
|
||||
return localPath, err
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func GetFFprobePath() (string, error) {
|
||||
ffprobeName := "ffprobe"
|
||||
if runtime.GOOS == "windows" {
|
||||
ffprobeName = "ffprobe.exe"
|
||||
}
|
||||
|
||||
if path := resolveSystemExecutable(ffprobeName); path != "" {
|
||||
return path, nil
|
||||
path, localPath, err := resolveExecutablePath(ffprobeName)
|
||||
if err != nil {
|
||||
if localPath != "" {
|
||||
return localPath, err
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
localPath := filepath.Join(ffmpegDir, ffprobeName)
|
||||
if _, err := os.Stat(localPath); err == nil {
|
||||
return localPath, nil
|
||||
}
|
||||
|
||||
return localPath, fmt.Errorf("ffprobe not found in app directory or system path")
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func IsFFprobeInstalled() (bool, error) {
|
||||
ffprobePath, err := GetFFprobePath()
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := ValidateExecutable(ffprobePath); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
cmd := exec.Command(ffprobePath, "-version")
|
||||
setHideWindow(cmd)
|
||||
err = cmd.Run()
|
||||
_, err := GetFFprobePath()
|
||||
return err == nil, nil
|
||||
}
|
||||
|
||||
func IsFFmpegInstalled() (bool, error) {
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if err := ValidateExecutable(ffmpegPath); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
cmd := exec.Command(ffmpegPath, "-version")
|
||||
|
||||
setHideWindow(cmd)
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
if _, err := GetFFmpegPath(); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -244,7 +374,7 @@ func InstallFFmpegWithBrew(progressCallback func(int, string)) error {
|
||||
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 {
|
||||
return ffmpegReleaseBaseURL + "/" + assetName
|
||||
@@ -507,6 +637,10 @@ func extractZip(zipPath, destDir string) error {
|
||||
return fmt.Errorf("failed to extract file: %w", err)
|
||||
}
|
||||
|
||||
if err := prepareExecutableForUse(destPath); err != nil {
|
||||
return fmt.Errorf("failed to prepare extracted executable: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
||||
}
|
||||
|
||||
@@ -584,6 +718,10 @@ func extractTarXz(tarXzPath, destDir string) error {
|
||||
return fmt.Errorf("failed to extract file: %w", err)
|
||||
}
|
||||
|
||||
if err := prepareExecutableForUse(destPath); err != nil {
|
||||
return fmt.Errorf("failed to prepare extracted executable: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
||||
}
|
||||
|
||||
@@ -733,6 +871,36 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
"-map", "0:a",
|
||||
)
|
||||
}
|
||||
case "wav", "aiff":
|
||||
sampleFmt, rawBits := pcmSampleFormatForInput(inputFile)
|
||||
pcmCodec := "pcm_s16le"
|
||||
if req.OutputFormat == "aiff" {
|
||||
pcmCodec = "pcm_s16be"
|
||||
}
|
||||
if sampleFmt == "s32" {
|
||||
if req.OutputFormat == "aiff" {
|
||||
pcmCodec = "pcm_s24be"
|
||||
} else {
|
||||
pcmCodec = "pcm_s24le"
|
||||
}
|
||||
}
|
||||
args = append(args,
|
||||
"-codec:a", pcmCodec,
|
||||
"-map", "0:a",
|
||||
)
|
||||
if rawBits > 0 {
|
||||
args = append(args, "-bits_per_raw_sample", strconv.Itoa(rawBits))
|
||||
}
|
||||
case "opus":
|
||||
bitrate := req.Bitrate
|
||||
if bitrate == "" {
|
||||
bitrate = "192k"
|
||||
}
|
||||
args = append(args,
|
||||
"-codec:a", "libopus",
|
||||
"-b:a", bitrate,
|
||||
"-map", "0:a",
|
||||
)
|
||||
}
|
||||
|
||||
args = append(args, outputFile)
|
||||
@@ -787,6 +955,13 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func pcmSampleFormatForInput(inputFile string) (sampleFmt string, rawBits int) {
|
||||
if meta, err := GetTrackMetadata(inputFile); err == nil && meta != nil && meta.BitsPerSample > 16 {
|
||||
return "s32", 24
|
||||
}
|
||||
return "s16", 0
|
||||
}
|
||||
|
||||
type AudioFileInfo struct {
|
||||
Path string `json:"path"`
|
||||
Filename string `json:"filename"`
|
||||
|
||||
+9
-8
@@ -149,14 +149,15 @@ func ClearHistory(appName string) error {
|
||||
}
|
||||
|
||||
type FetchHistoryItem struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Info string `json:"info"`
|
||||
Image string `json:"image"`
|
||||
Data string `json:"data"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Info string `json:"info"`
|
||||
Image string `json:"image"`
|
||||
Data string `json:"data"`
|
||||
IsExplicit bool `json:"is_explicit,omitempty"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
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
|
||||
speedLock sync.RWMutex
|
||||
|
||||
rateLimitUntilMs int64
|
||||
rateLimitLock sync.RWMutex
|
||||
|
||||
downloadQueue []DownloadItem
|
||||
downloadQueueLock sync.RWMutex
|
||||
currentItemID string
|
||||
@@ -55,6 +58,8 @@ type ProgressInfo struct {
|
||||
IsDownloading bool `json:"is_downloading"`
|
||||
MBDownloaded float64 `json:"mb_downloaded"`
|
||||
SpeedMBps float64 `json:"speed_mbps"`
|
||||
RateLimited bool `json:"rate_limited"`
|
||||
RateLimitSecs int `json:"rate_limit_secs"`
|
||||
}
|
||||
|
||||
type DownloadQueueInfo struct {
|
||||
@@ -82,13 +87,45 @@ func GetDownloadProgress() ProgressInfo {
|
||||
speed := currentSpeed
|
||||
speedLock.RUnlock()
|
||||
|
||||
rateLimitLock.RLock()
|
||||
untilMs := rateLimitUntilMs
|
||||
rateLimitLock.RUnlock()
|
||||
|
||||
rateLimited := false
|
||||
rateLimitSecs := 0
|
||||
if untilMs > 0 {
|
||||
remainingMs := untilMs - getCurrentTimeMillis()
|
||||
if remainingMs > 0 {
|
||||
rateLimited = true
|
||||
rateLimitSecs = int((remainingMs + 999) / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
return ProgressInfo{
|
||||
IsDownloading: downloading,
|
||||
MBDownloaded: progress,
|
||||
SpeedMBps: speed,
|
||||
RateLimited: rateLimited,
|
||||
RateLimitSecs: rateLimitSecs,
|
||||
}
|
||||
}
|
||||
|
||||
func SetRateLimitCooldown(seconds float64) {
|
||||
rateLimitLock.Lock()
|
||||
if seconds <= 0 {
|
||||
rateLimitUntilMs = 0
|
||||
} else {
|
||||
rateLimitUntilMs = getCurrentTimeMillis() + int64(seconds*1000)
|
||||
}
|
||||
rateLimitLock.Unlock()
|
||||
}
|
||||
|
||||
func ClearRateLimitCooldown() {
|
||||
rateLimitLock.Lock()
|
||||
rateLimitUntilMs = 0
|
||||
rateLimitLock.Unlock()
|
||||
}
|
||||
|
||||
func SetDownloadSpeed(mbps float64) {
|
||||
speedLock.Lock()
|
||||
currentSpeed = mbps
|
||||
@@ -110,6 +147,7 @@ func SetDownloading(downloading bool) {
|
||||
|
||||
SetDownloadProgress(0)
|
||||
SetDownloadSpeed(0)
|
||||
ClearRateLimitCooldown()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +185,10 @@ func getCurrentTimeMillis() int64 {
|
||||
}
|
||||
|
||||
func (pw *ProgressWriter) Write(p []byte) (int, error) {
|
||||
if err := CheckDownloadCancelled(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
n, err := pw.writer.Write(p)
|
||||
pw.total += int64(n)
|
||||
|
||||
@@ -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() {
|
||||
downloadQueueLock.RLock()
|
||||
hasActiveOrQueued := false
|
||||
|
||||
@@ -1,15 +1,86 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const amazonMusicAPIBaseURL = "https://amazon.spotbye.qzz.io"
|
||||
|
||||
var defaultQobuzStreamAPIBaseURLs = []string{
|
||||
"https://dab.yeet.su/api/stream?trackId=",
|
||||
"https://dabmusic.xyz/api/stream?trackId=",
|
||||
"https://qobuz.spotbye.qzz.io/api/track/",
|
||||
const (
|
||||
qobuzWJHEBaseURL = "https://music.wjhe.top"
|
||||
qobuzWJHESearchAPIURL = qobuzWJHEBaseURL + "/api/music/qobuz/search"
|
||||
qobuzWJHEStreamAPIURL = qobuzWJHEBaseURL + "/api/music/qobuz/url"
|
||||
qobuzMusicDLDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download"
|
||||
qobuzGDStudioAPIURLXYZ = "https://music.gdstudio.xyz/api.php"
|
||||
qobuzGDStudioAPIURLORG = "https://music.gdstudio.org/api.php"
|
||||
qobuzGDStudioVersion = "2026.5.10"
|
||||
)
|
||||
|
||||
var defaultQobuzDownloadProviderURLs = []string{
|
||||
qobuzWJHEStreamAPIURL,
|
||||
qobuzGDStudioAPIURLXYZ,
|
||||
qobuzGDStudioAPIURLORG,
|
||||
qobuzMusicDLDownloadAPIURL,
|
||||
}
|
||||
|
||||
func GetQobuzStreamAPIBaseURLs() []string {
|
||||
return append([]string(nil), defaultQobuzStreamAPIBaseURLs...)
|
||||
func GetQobuzDownloadProviderURLs() []string {
|
||||
return append([]string(nil), defaultQobuzDownloadProviderURLs...)
|
||||
}
|
||||
|
||||
func GetQobuzWJHESearchAPIURL() string {
|
||||
return qobuzWJHESearchAPIURL
|
||||
}
|
||||
|
||||
func GetQobuzWJHEStreamAPIURL() string {
|
||||
return qobuzWJHEStreamAPIURL
|
||||
}
|
||||
|
||||
func GetQobuzMusicDLDownloadAPIURL() string {
|
||||
return qobuzMusicDLDownloadAPIURL
|
||||
}
|
||||
|
||||
func GetQobuzGDStudioAPIURLs() []string {
|
||||
return []string{qobuzGDStudioAPIURLXYZ, qobuzGDStudioAPIURLORG}
|
||||
}
|
||||
|
||||
func GetQobuzGDStudioPrimaryAPIURL() string {
|
||||
return qobuzGDStudioAPIURLXYZ
|
||||
}
|
||||
|
||||
func GetQobuzGDStudioFallbackAPIURL() string {
|
||||
return qobuzGDStudioAPIURLORG
|
||||
}
|
||||
|
||||
func GetQobuzGDStudioSignatureHost(apiURL string) string {
|
||||
parsed, err := url.Parse(strings.TrimSpace(apiURL))
|
||||
if err != nil || strings.TrimSpace(parsed.Host) == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(parsed.Host)
|
||||
}
|
||||
|
||||
func GetQobuzGDStudioVersion() string {
|
||||
return qobuzGDStudioVersion
|
||||
}
|
||||
|
||||
func IsQobuzWJHEProviderURL(raw string) bool {
|
||||
candidate := strings.TrimSpace(raw)
|
||||
return candidate == qobuzWJHEStreamAPIURL || strings.HasPrefix(candidate, qobuzWJHEStreamAPIURL+"?")
|
||||
}
|
||||
|
||||
func IsQobuzMusicDLProviderURL(raw string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(raw), qobuzMusicDLDownloadAPIURL)
|
||||
}
|
||||
|
||||
func IsQobuzGDStudioProviderURL(raw string) bool {
|
||||
candidate := strings.TrimSpace(raw)
|
||||
for _, apiURL := range GetQobuzGDStudioAPIURLs() {
|
||||
if candidate == apiURL || strings.HasPrefix(candidate, apiURL+"?") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func GetAmazonMusicAPIBaseURL() string {
|
||||
|
||||
+693
-94
@@ -1,6 +1,12 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -9,23 +15,19 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type QobuzDownloader struct {
|
||||
client *http.Client
|
||||
appID string
|
||||
client *http.Client
|
||||
customURL string
|
||||
}
|
||||
|
||||
type QobuzSearchResponse struct {
|
||||
Query string `json:"query"`
|
||||
Tracks struct {
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
Total int `json:"total"`
|
||||
Items []QobuzTrack `json:"items"`
|
||||
} `json:"tracks"`
|
||||
func (q *QobuzDownloader) SetCustomAPIURL(apiURL string) {
|
||||
q.customURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||
}
|
||||
|
||||
type QobuzTrack struct {
|
||||
@@ -64,8 +66,63 @@ type QobuzTrack struct {
|
||||
} `json:"album"`
|
||||
}
|
||||
|
||||
type QobuzStreamResponse struct {
|
||||
URL string `json:"url"`
|
||||
type qobuzMusicDLRequest struct {
|
||||
URL string `json:"url"`
|
||||
Quality string `json:"quality"`
|
||||
}
|
||||
|
||||
type qobuzMusicDLResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Type string `json:"type"`
|
||||
URLType string `json:"url_type"`
|
||||
TrackID string `json:"track_id"`
|
||||
Quality string `json:"quality_label"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type qobuzPublicSearchResponse struct {
|
||||
Tracks struct {
|
||||
Total int `json:"total"`
|
||||
Items []QobuzTrack `json:"items"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
const qobuzProbeTrackID int64 = 341032040
|
||||
|
||||
var (
|
||||
qobuzMusicDLDebugKeyOnce sync.Once
|
||||
qobuzMusicDLDebugKey string
|
||||
qobuzMusicDLDebugKeyErr error
|
||||
qobuzStreamingURLPattern = regexp.MustCompile(`https?://[^\s"'<>\\)]+`)
|
||||
)
|
||||
|
||||
var qobuzMusicDLDebugKeySeedParts = [][]byte{
|
||||
{0x73, 0x70, 0x6f, 0x74, 0x69, 0x66},
|
||||
{0x6c, 0x61, 0x63, 0x3a, 0x71, 0x6f},
|
||||
{0x62, 0x75, 0x7a, 0x3a, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x64, 0x6c, 0x3a, 0x76, 0x31},
|
||||
}
|
||||
|
||||
var qobuzMusicDLDebugKeyAAD = []byte{
|
||||
0x71, 0x6f, 0x62, 0x75, 0x7a, 0x7c, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x64,
|
||||
0x6c, 0x7c, 0x64, 0x65, 0x62, 0x75, 0x67, 0x7c, 0x76, 0x31,
|
||||
}
|
||||
|
||||
var qobuzMusicDLDebugKeyNonce = []byte{
|
||||
0x91, 0x2a, 0x5c, 0x77, 0x0f, 0x33, 0xa8, 0x14, 0x62, 0x9d, 0xce, 0x41,
|
||||
}
|
||||
|
||||
var qobuzMusicDLDebugKeyCiphertext = []byte{
|
||||
0xf3, 0x4a, 0x83, 0x45, 0x24, 0xb6, 0x22, 0xaf, 0xd6, 0xc3, 0x6e, 0x2d,
|
||||
0x56, 0xd1, 0xbb, 0x0b, 0xe9, 0x1b, 0x4f, 0x1c, 0x5f, 0x41, 0x55, 0xc2,
|
||||
0xc6, 0xdf, 0xad, 0x21, 0x58, 0xfe, 0xd5, 0xb8, 0x2d, 0x29, 0xf9, 0x9e,
|
||||
0x6f, 0xd6,
|
||||
}
|
||||
|
||||
var qobuzMusicDLDebugKeyTag = []byte{
|
||||
0x69, 0x0c, 0x42, 0x70, 0x14, 0x83, 0xff, 0x14, 0xc8, 0xbe, 0x17, 0x00,
|
||||
0x69, 0xb1, 0xfe, 0xbb,
|
||||
}
|
||||
|
||||
func NewQobuzDownloader() *QobuzDownloader {
|
||||
@@ -73,119 +130,625 @@ func NewQobuzDownloader() *QobuzDownloader {
|
||||
client: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
},
|
||||
appID: qobuzDefaultAPIAppID,
|
||||
}
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
|
||||
func previewQobuzResponseBody(body []byte, maxLen int) string {
|
||||
preview := strings.TrimSpace(string(body))
|
||||
if len(preview) > maxLen {
|
||||
return preview[:maxLen] + "..."
|
||||
}
|
||||
return preview
|
||||
}
|
||||
|
||||
func buildQobuzOpenTrackURL(trackID int64) string {
|
||||
return fmt.Sprintf("https://open.qobuz.com/track/%d", trackID)
|
||||
}
|
||||
|
||||
func getQobuzMusicDLDebugKey() (string, error) {
|
||||
qobuzMusicDLDebugKeyOnce.Do(func() {
|
||||
hasher := sha256.New()
|
||||
for _, part := range qobuzMusicDLDebugKeySeedParts {
|
||||
hasher.Write(part)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(hasher.Sum(nil))
|
||||
if err != nil {
|
||||
qobuzMusicDLDebugKeyErr = err
|
||||
return
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
qobuzMusicDLDebugKeyErr = err
|
||||
return
|
||||
}
|
||||
|
||||
sealed := make([]byte, 0, len(qobuzMusicDLDebugKeyCiphertext)+len(qobuzMusicDLDebugKeyTag))
|
||||
sealed = append(sealed, qobuzMusicDLDebugKeyCiphertext...)
|
||||
sealed = append(sealed, qobuzMusicDLDebugKeyTag...)
|
||||
|
||||
plaintext, err := gcm.Open(nil, qobuzMusicDLDebugKeyNonce, sealed, qobuzMusicDLDebugKeyAAD)
|
||||
if err != nil {
|
||||
qobuzMusicDLDebugKeyErr = err
|
||||
return
|
||||
}
|
||||
|
||||
qobuzMusicDLDebugKey = string(plaintext)
|
||||
})
|
||||
|
||||
if qobuzMusicDLDebugKeyErr != nil {
|
||||
return "", qobuzMusicDLDebugKeyErr
|
||||
}
|
||||
|
||||
return qobuzMusicDLDebugKey, nil
|
||||
}
|
||||
|
||||
func firstNonEmptyQobuzValue(values ...string) string {
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeQobuzSearchValue(value string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"&", " and ",
|
||||
"feat.", " ",
|
||||
"ft.", " ",
|
||||
"/", " ",
|
||||
"-", " ",
|
||||
"_", " ",
|
||||
)
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
normalized = replacer.Replace(normalized)
|
||||
return strings.Join(strings.Fields(normalized), " ")
|
||||
}
|
||||
|
||||
func qobuzTrackDisplayArtist(track QobuzTrack) string {
|
||||
return firstNonEmptyQobuzValue(track.Performer.Name, track.Album.Artist.Name)
|
||||
}
|
||||
|
||||
func qobuzTrackSupportsHiRes(track QobuzTrack) bool {
|
||||
if track.Hires || track.HiresStreamable {
|
||||
return true
|
||||
}
|
||||
return track.MaximumBitDepth >= 24 || track.MaximumSamplingRate > 48
|
||||
}
|
||||
|
||||
func scoreQobuzSearchCandidate(track QobuzTrack, spotifyTrackName string, spotifyArtistName string, spotifyAlbumName string) int {
|
||||
score := 0
|
||||
|
||||
titleNeedle := normalizeQobuzSearchValue(spotifyTrackName)
|
||||
titleHaystack := normalizeQobuzSearchValue(track.Title)
|
||||
switch {
|
||||
case titleNeedle != "" && titleHaystack == titleNeedle:
|
||||
score += 1000
|
||||
case titleNeedle != "" && (strings.Contains(titleHaystack, titleNeedle) || strings.Contains(titleNeedle, titleHaystack)):
|
||||
score += 500
|
||||
}
|
||||
|
||||
artistNeedle := normalizeQobuzSearchValue(spotifyArtistName)
|
||||
artistHaystack := normalizeQobuzSearchValue(qobuzTrackDisplayArtist(track))
|
||||
switch {
|
||||
case artistNeedle != "" && artistHaystack == artistNeedle:
|
||||
score += 300
|
||||
case artistNeedle != "" && artistHaystack != "" && (strings.Contains(artistHaystack, artistNeedle) || strings.Contains(artistNeedle, artistHaystack)):
|
||||
score += 180
|
||||
}
|
||||
|
||||
albumNeedle := normalizeQobuzSearchValue(spotifyAlbumName)
|
||||
albumHaystack := normalizeQobuzSearchValue(track.Album.Title)
|
||||
switch {
|
||||
case albumNeedle != "" && albumHaystack == albumNeedle:
|
||||
score += 150
|
||||
case albumNeedle != "" && albumHaystack != "" && (strings.Contains(albumHaystack, albumNeedle) || strings.Contains(albumNeedle, albumHaystack)):
|
||||
score += 90
|
||||
}
|
||||
|
||||
if qobuzTrackSupportsHiRes(track) {
|
||||
score += 40
|
||||
} else if track.MaximumBitDepth >= 16 {
|
||||
score += 20
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
func mapQobuzWJHEQuality(quality string) (int, string) {
|
||||
switch strings.TrimSpace(quality) {
|
||||
case "27", "7":
|
||||
return 2000, "flac"
|
||||
case "", "6":
|
||||
return 1000, "flac"
|
||||
default:
|
||||
return 320, "mp3"
|
||||
}
|
||||
}
|
||||
|
||||
func buildQobuzWJHEDownloadURL(trackID int64, quality string) string {
|
||||
wjheQuality, wjheFormat := mapQobuzWJHEQuality(quality)
|
||||
params := url.Values{
|
||||
"ID": {strconv.FormatInt(trackID, 10)},
|
||||
"quality": {strconv.Itoa(wjheQuality)},
|
||||
"format": {wjheFormat},
|
||||
}
|
||||
return GetQobuzWJHEStreamAPIURL() + "?" + params.Encode()
|
||||
}
|
||||
|
||||
func qobuzURLLooksStreamable(raw string) bool {
|
||||
candidate := strings.TrimSpace(raw)
|
||||
if candidate == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(candidate)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != ""
|
||||
}
|
||||
|
||||
func findQobuzStreamingURLInPayload(payload interface{}) string {
|
||||
switch value := payload.(type) {
|
||||
case string:
|
||||
candidate := strings.ReplaceAll(strings.TrimSpace(value), `\/`, `/`)
|
||||
if qobuzURLLooksStreamable(candidate) {
|
||||
return candidate
|
||||
}
|
||||
case []interface{}:
|
||||
for _, item := range value {
|
||||
if url := findQobuzStreamingURLInPayload(item); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
case map[string]interface{}:
|
||||
for _, key := range []string{"download_url", "url", "play_url", "stream_url", "link", "file"} {
|
||||
if nested, ok := value[key]; ok {
|
||||
if url := findQobuzStreamingURLInPayload(nested); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, nested := range value {
|
||||
if url := findQobuzStreamingURLInPayload(nested); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractQobuzStreamingURL(body []byte) string {
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var directResp struct {
|
||||
URL string `json:"url"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
Data struct {
|
||||
URL string `json:"url"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &directResp); err == nil {
|
||||
for _, candidate := range []string{
|
||||
directResp.DownloadURL,
|
||||
directResp.URL,
|
||||
directResp.Data.DownloadURL,
|
||||
directResp.Data.URL,
|
||||
} {
|
||||
if qobuzURLLooksStreamable(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var genericPayload interface{}
|
||||
if err := json.Unmarshal(body, &genericPayload); err == nil {
|
||||
if streamURL := findQobuzStreamingURLInPayload(genericPayload); streamURL != "" {
|
||||
return streamURL
|
||||
}
|
||||
}
|
||||
|
||||
if openIdx := strings.Index(trimmed, "("); openIdx >= 0 {
|
||||
if closeIdx := strings.LastIndex(trimmed, ")"); closeIdx > openIdx+1 {
|
||||
callbackBody := strings.TrimSpace(trimmed[openIdx+1 : closeIdx])
|
||||
if streamURL := extractQobuzStreamingURL([]byte(callbackBody)); streamURL != "" {
|
||||
return streamURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, match := range qobuzStreamingURLPattern.FindAllString(trimmed, -1) {
|
||||
candidate := strings.ReplaceAll(match, `\/`, `/`)
|
||||
if qobuzURLLooksStreamable(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func newQobuzNoRedirectClient(base *http.Client) *http.Client {
|
||||
if base == nil {
|
||||
return &http.Client{
|
||||
Timeout: 20 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
cloned := *base
|
||||
if cloned.Timeout == 0 {
|
||||
cloned.Timeout = 20 * time.Second
|
||||
}
|
||||
cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) searchByISRC(isrc string, spotifyTrackName string, spotifyArtistName string, spotifyAlbumName string) (*QobuzTrack, error) {
|
||||
if strings.HasPrefix(isrc, "qobuz_") {
|
||||
trackID := strings.TrimPrefix(isrc, "qobuz_")
|
||||
trackID := strings.TrimSpace(strings.TrimPrefix(isrc, "qobuz_"))
|
||||
resp, err := doQobuzSignedRequest(http.MethodGet, "track/get", url.Values{"track_id": {trackID}}, q.client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch track: %w", err)
|
||||
return nil, fmt.Errorf("failed to fetch track from Qobuz public API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("Qobuz public API track/get returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
|
||||
}
|
||||
|
||||
var trackResp QobuzTrack
|
||||
if err := json.NewDecoder(resp.Body).Decode(&trackResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, fmt.Errorf("failed to decode Qobuz public track/get response: %w", err)
|
||||
}
|
||||
|
||||
return &trackResp, nil
|
||||
}
|
||||
|
||||
resp, err := doQobuzSignedRequest(http.MethodGet, "track/search", url.Values{
|
||||
"query": {isrc},
|
||||
"limit": {"1"},
|
||||
}, q.client)
|
||||
queries := []string{strings.TrimSpace(isrc)}
|
||||
if fallbackQuery := strings.TrimSpace(strings.Join([]string{spotifyTrackName, spotifyArtistName}, " ")); fallbackQuery != "" {
|
||||
queries = append(queries, fallbackQuery)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, query := range queries {
|
||||
if strings.TrimSpace(query) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var searchResp qobuzPublicSearchResponse
|
||||
if err := doQobuzSignedJSONRequest("track/search", url.Values{
|
||||
"query": {strings.TrimSpace(query)},
|
||||
"limit": {"10"},
|
||||
}, &searchResp); err != nil {
|
||||
lastErr = fmt.Errorf("failed to search Qobuz public API: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if searchResp.Tracks.Total == 0 || len(searchResp.Tracks.Items) == 0 {
|
||||
lastErr = fmt.Errorf("track not found for query: %s", query)
|
||||
continue
|
||||
}
|
||||
|
||||
bestIndex := 0
|
||||
bestScore := -1
|
||||
for idx, candidate := range searchResp.Tracks.Items {
|
||||
score := scoreQobuzSearchCandidate(candidate, spotifyTrackName, spotifyArtistName, spotifyAlbumName)
|
||||
if idx == 0 || score > bestScore {
|
||||
bestIndex = idx
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
|
||||
selected := searchResp.Tracks.Items[bestIndex]
|
||||
return &selected, nil
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("track not found for ISRC: %s", isrc)
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadFromWJHE(trackID int64, quality string) (string, error) {
|
||||
apiURL := buildQobuzWJHEDownloadURL(trackID, quality)
|
||||
client := newQobuzNoRedirectClient(q.client)
|
||||
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodHead, apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to search track: %w", err)
|
||||
return "", fmt.Errorf("failed to create WJHE request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to reach WJHE: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotImplemented {
|
||||
resp.Body.Close()
|
||||
req, err = NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create WJHE fallback request: %w", err)
|
||||
}
|
||||
resp, err = client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to reach WJHE with GET fallback: %w", err)
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
if location := strings.TrimSpace(resp.Header.Get("Location")); qobuzURLLooksStreamable(location) {
|
||||
return location, nil
|
||||
}
|
||||
|
||||
var searchResp QobuzSearchResponse
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 128*1024))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
return "", fmt.Errorf("failed to read WJHE response: %w", err)
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return nil, fmt.Errorf("API returned empty response")
|
||||
if streamURL := extractQobuzStreamingURL(body); streamURL != "" {
|
||||
return streamURL, nil
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &searchResp); err != nil {
|
||||
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
if streamURL := strings.TrimSpace(resp.Request.URL.String()); streamURL != "" && streamURL != apiURL && qobuzURLLooksStreamable(streamURL) {
|
||||
return streamURL, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
|
||||
}
|
||||
|
||||
if len(searchResp.Tracks.Items) == 0 {
|
||||
return nil, fmt.Errorf("track not found for ISRC: %s", isrc)
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
|
||||
return "", fmt.Errorf("WJHE returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
|
||||
}
|
||||
|
||||
return &searchResp.Tracks.Items[0], nil
|
||||
return "", fmt.Errorf("WJHE response did not include a stream URL")
|
||||
}
|
||||
|
||||
func buildQobuzAPIURL(apiBase string, trackID int64, quality string) string {
|
||||
if strings.Contains(apiBase, "qobuz.spotbye.qzz.io") {
|
||||
return fmt.Sprintf("%s%d?quality=%s", apiBase, trackID, quality)
|
||||
func qobuzGDStudioPaddedVersion() string {
|
||||
parts := strings.Split(GetQobuzGDStudioVersion(), ".")
|
||||
for idx, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if len(part) == 1 {
|
||||
part = "0" + part
|
||||
}
|
||||
parts[idx] = part
|
||||
}
|
||||
return fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, quality string) (string, error) {
|
||||
apiURL := buildQobuzAPIURL(apiBase, trackID, quality)
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
|
||||
func qobuzGDStudioEscapedValue(value string) string {
|
||||
return strings.ReplaceAll(url.QueryEscape(strings.TrimSpace(value)), "+", "%20")
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) getQobuzGDStudioTS9(apiURL string) string {
|
||||
fallback := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
if len(fallback) >= 9 {
|
||||
fallback = fallback[:9]
|
||||
}
|
||||
|
||||
client := q.client
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
|
||||
signatureHost := GetQobuzGDStudioSignatureHost(apiURL)
|
||||
if signatureHost == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, fmt.Sprintf("https://%s/time", signatureHost), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return fallback
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64))
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
timestamp := strings.TrimSpace(string(body))
|
||||
if len(timestamp) >= 9 {
|
||||
return timestamp[:9]
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
func buildQobuzGDStudioSignature(apiURL string, value string, ts9 string) string {
|
||||
signatureHost := GetQobuzGDStudioSignatureHost(apiURL)
|
||||
signatureBase := fmt.Sprintf("%s|%s|%s|%s", signatureHost, qobuzGDStudioPaddedVersion(), ts9, qobuzGDStudioEscapedValue(value))
|
||||
sum := md5.Sum([]byte(signatureBase))
|
||||
digest := hex.EncodeToString(sum[:])
|
||||
return strings.ToUpper(digest[len(digest)-8:])
|
||||
}
|
||||
|
||||
func mapQobuzGDStudioBitrate(quality string) string {
|
||||
switch strings.TrimSpace(quality) {
|
||||
case "27", "7":
|
||||
return "999"
|
||||
case "", "6":
|
||||
return "740"
|
||||
default:
|
||||
return "320"
|
||||
}
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadFromGDStudio(trackID int64, quality string, apiURL string) (string, error) {
|
||||
apiURL = strings.TrimSpace(apiURL)
|
||||
if apiURL == "" {
|
||||
apiURL = GetQobuzGDStudioPrimaryAPIURL()
|
||||
}
|
||||
|
||||
signatureHost := GetQobuzGDStudioSignatureHost(apiURL)
|
||||
if signatureHost == "" {
|
||||
return "", fmt.Errorf("GDStudio API URL is invalid: %s", apiURL)
|
||||
}
|
||||
|
||||
trackIDString := strconv.FormatInt(trackID, 10)
|
||||
ts9 := q.getQobuzGDStudioTS9(apiURL)
|
||||
payload := url.Values{
|
||||
"types": {"url"},
|
||||
"id": {trackIDString},
|
||||
"source": {"qobuz"},
|
||||
"br": {mapQobuzGDStudioBitrate(quality)},
|
||||
"s": {buildQobuzGDStudioSignature(apiURL, trackIDString, ts9)},
|
||||
}
|
||||
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodPost, apiURL, strings.NewReader(payload.Encode()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create GDStudio request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
req.Header.Set("Origin", fmt.Sprintf("https://%s", signatureHost))
|
||||
req.Header.Set("Referer", fmt.Sprintf("https://%s/", signatureHost))
|
||||
|
||||
resp, err := q.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("failed to reach GDStudio: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("status %d", resp.StatusCode)
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read GDStudio response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("GDStudio returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
|
||||
}
|
||||
|
||||
streamURL := extractQobuzStreamingURL(body)
|
||||
if streamURL == "" {
|
||||
return "", fmt.Errorf("GDStudio response did not include a stream URL: %s", previewQobuzResponseBody(body, 256))
|
||||
}
|
||||
|
||||
return streamURL, nil
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadFromMusicDL(trackID int64, quality string) (string, error) {
|
||||
if strings.TrimSpace(quality) == "" {
|
||||
quality = "6"
|
||||
}
|
||||
|
||||
debugKey, err := getQobuzMusicDLDebugKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decrypt MusicDL debug key: %w", err)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(qobuzMusicDLRequest{
|
||||
URL: buildQobuzOpenTrackURL(trackID),
|
||||
Quality: strings.TrimSpace(quality),
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encode MusicDL request: %w", err)
|
||||
}
|
||||
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodPost, GetQobuzMusicDLDownloadAPIURL(), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create MusicDL request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Debug-Key", debugKey)
|
||||
|
||||
resp, err := q.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to reach MusicDL: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("failed to read MusicDL response: %w", err)
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return "", fmt.Errorf("empty body")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("MusicDL returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
|
||||
}
|
||||
|
||||
var streamResp QobuzStreamResponse
|
||||
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
|
||||
return streamResp.URL, nil
|
||||
var downloadResp qobuzMusicDLResponse
|
||||
if err := json.Unmarshal(body, &downloadResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode MusicDL response: %w (%s)", err, previewQobuzResponseBody(body, 256))
|
||||
}
|
||||
|
||||
var nestedResp struct {
|
||||
Data struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &nestedResp); err == nil && nestedResp.Data.URL != "" {
|
||||
return nestedResp.Data.URL, nil
|
||||
if !downloadResp.Success {
|
||||
message := strings.TrimSpace(downloadResp.Error)
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(downloadResp.Message)
|
||||
}
|
||||
if message == "" {
|
||||
message = "MusicDL reported failure"
|
||||
}
|
||||
return "", fmt.Errorf("%s", message)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("invalid response")
|
||||
downloadURL := strings.TrimSpace(downloadResp.DownloadURL)
|
||||
if downloadURL == "" {
|
||||
return "", fmt.Errorf("MusicDL response did not include a download_url")
|
||||
}
|
||||
|
||||
return downloadURL, nil
|
||||
}
|
||||
|
||||
func CheckQobuzMusicDLStatusDetailed(client *http.Client) error {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 4 * time.Second}
|
||||
}
|
||||
|
||||
downloader := &QobuzDownloader{client: client}
|
||||
_, err := downloader.DownloadFromMusicDL(qobuzProbeTrackID, "27")
|
||||
return err
|
||||
}
|
||||
|
||||
func CheckQobuzMusicDLStatus(client *http.Client) bool {
|
||||
return CheckQobuzMusicDLStatusDetailed(client) == nil
|
||||
}
|
||||
|
||||
func CheckQobuzWJHEStatusDetailed(client *http.Client) error {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 4 * time.Second}
|
||||
}
|
||||
|
||||
downloader := &QobuzDownloader{client: client}
|
||||
_, err := downloader.DownloadFromWJHE(qobuzProbeTrackID, "27")
|
||||
return err
|
||||
}
|
||||
|
||||
func CheckQobuzWJHEStatus(client *http.Client) bool {
|
||||
return CheckQobuzWJHEStatusDetailed(client) == nil
|
||||
}
|
||||
|
||||
func CheckQobuzGDStudioAPIStatusDetailed(client *http.Client, apiURL string) error {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 4 * time.Second}
|
||||
}
|
||||
|
||||
downloader := &QobuzDownloader{client: client}
|
||||
_, err := downloader.DownloadFromGDStudio(qobuzProbeTrackID, "27", apiURL)
|
||||
return err
|
||||
}
|
||||
|
||||
func CheckQobuzGDStudioAPIStatus(client *http.Client, apiURL string) bool {
|
||||
return CheckQobuzGDStudioAPIStatusDetailed(client, apiURL) == nil
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFallback bool) (string, error) {
|
||||
@@ -196,41 +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)
|
||||
|
||||
standardAPIs := prioritizeProviders("qobuz", GetQobuzStreamAPIBaseURLs())
|
||||
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) {
|
||||
type Provider struct {
|
||||
Name string
|
||||
API string
|
||||
Func func() (string, error)
|
||||
if url, err := q.getQobuzCommunityDownloadURL(trackID, qual); err == nil {
|
||||
fmt.Printf("Success (community qbz-a)\n")
|
||||
return url, nil
|
||||
} else if IsDownloadCancelledError(err) {
|
||||
return "", err
|
||||
} else {
|
||||
fmt.Printf("Community qbz-a failed: %v\n", err)
|
||||
}
|
||||
|
||||
var providers []Provider
|
||||
|
||||
for _, api := range standardAPIs {
|
||||
currentAPI := api
|
||||
providers = append(providers, Provider{
|
||||
Name: "Standard(" + currentAPI + ")",
|
||||
API: currentAPI,
|
||||
Func: func() (string, error) {
|
||||
return q.DownloadFromStandard(currentAPI, trackID, qual)
|
||||
},
|
||||
})
|
||||
attemptMap := make(map[string]qobuzProviderAttempt)
|
||||
attemptIDs := make([]string, 0, len(GetQobuzDownloadProviderURLs()))
|
||||
for _, provider := range q.getQobuzDownloadProviders() {
|
||||
for _, attempt := range provider.Attempts(trackID, qual) {
|
||||
attemptMap[attempt.ID] = attempt
|
||||
attemptIDs = append(attemptIDs, attempt.ID)
|
||||
}
|
||||
}
|
||||
|
||||
orderedProviderIDs := prioritizeProviders("qobuz", attemptIDs)
|
||||
orderedProviderIDs = moveQobuzAttemptIDsLast(orderedProviderIDs, GetQobuzMusicDLDownloadAPIURL())
|
||||
var lastErr error
|
||||
for _, p := range providers {
|
||||
fmt.Printf("Trying Provider: %s (Quality: %s)...\n", p.Name, qual)
|
||||
for _, providerID := range orderedProviderIDs {
|
||||
attempt, ok := attemptMap[providerID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
url, err := p.Func()
|
||||
fmt.Printf("Trying Provider: %s (Quality: %s)...\n", attempt.Name, qual)
|
||||
|
||||
url, err := attempt.Download()
|
||||
if err == nil {
|
||||
fmt.Printf("✓ Success\n")
|
||||
recordProviderSuccess("qobuz", p.API)
|
||||
fmt.Printf("Success\n")
|
||||
recordProviderSuccess("qobuz", attempt.ID)
|
||||
return url, nil
|
||||
}
|
||||
|
||||
fmt.Printf("Provider failed: %v\n", err)
|
||||
recordProviderFailure("qobuz", p.API)
|
||||
recordProviderFailure("qobuz", attempt.ID)
|
||||
lastErr = err
|
||||
}
|
||||
return "", lastErr
|
||||
@@ -240,27 +824,36 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
|
||||
if err == nil {
|
||||
return url, nil
|
||||
}
|
||||
if IsDownloadCancelledError(err) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
currentQuality := qualityCode
|
||||
|
||||
if currentQuality == "27" && allowFallback {
|
||||
fmt.Printf("⚠ Download with quality 27 failed, trying fallback to 7 (24-bit Standard)...\n")
|
||||
fmt.Printf("Download with quality 27 failed, trying fallback to 7 (24-bit Standard)...\n")
|
||||
url, err := downloadFunc("7")
|
||||
if err == nil {
|
||||
fmt.Println("✓ Success with fallback quality 7")
|
||||
fmt.Println("Success with fallback quality 7")
|
||||
return url, nil
|
||||
}
|
||||
if IsDownloadCancelledError(err) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
currentQuality = "7"
|
||||
}
|
||||
|
||||
if currentQuality == "7" && allowFallback {
|
||||
fmt.Printf("⚠ Download with quality 7 failed, trying fallback to 6 (16-bit Lossless)...\n")
|
||||
fmt.Printf("Download with quality 7 failed, trying fallback to 6 (16-bit Lossless)...\n")
|
||||
url, err := downloadFunc("6")
|
||||
if err == nil {
|
||||
fmt.Println("✓ Success with fallback quality 6")
|
||||
fmt.Println("Success with fallback quality 6")
|
||||
return url, nil
|
||||
}
|
||||
if IsDownloadCancelledError(err) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("all APIs and fallbacks failed. Last error: %v", err)
|
||||
@@ -425,7 +1018,7 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena
|
||||
} else {
|
||||
fmt.Println("Fetching MusicBrainz metadata...")
|
||||
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
|
||||
fmt.Println("✓ MusicBrainz metadata fetched")
|
||||
fmt.Println("MusicBrainz metadata fetched")
|
||||
metaChan <- fetchedMeta
|
||||
} else {
|
||||
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
||||
@@ -443,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 {
|
||||
return "", err
|
||||
}
|
||||
@@ -457,7 +1050,13 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena
|
||||
|
||||
qualityInfo := "Standard"
|
||||
if track.Hires {
|
||||
qualityInfo = fmt.Sprintf("Hi-Res (%d-bit / %.1f kHz)", track.MaximumBitDepth, track.MaximumSamplingRate)
|
||||
if track.MaximumBitDepth > 0 && track.MaximumSamplingRate > 0 {
|
||||
qualityInfo = fmt.Sprintf("Hi-Res (%d-bit / %.1f kHz)", track.MaximumBitDepth, track.MaximumSamplingRate)
|
||||
} else if track.MaximumBitDepth > 0 {
|
||||
qualityInfo = fmt.Sprintf("Hi-Res available (%d-bit)", track.MaximumBitDepth)
|
||||
} else {
|
||||
qualityInfo = "Hi-Res available"
|
||||
}
|
||||
}
|
||||
fmt.Printf("Quality: %s\n", qualityInfo)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
type RecentFetchItem struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Artist string `json:"artist"`
|
||||
Image string `json:"image"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Artist string `json:"artist"`
|
||||
Image string `json:"image"`
|
||||
IsExplicit bool `json:"is_explicit,omitempty"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
+3
-3
@@ -420,17 +420,17 @@ func mergeSongLinkResponse(links *resolvedTrackLinks, resp *songLinkAPIResponse)
|
||||
|
||||
if link, ok := resp.LinksByPlatform["tidal"]; ok && link.URL != "" && links.TidalURL == "" {
|
||||
links.TidalURL = strings.TrimSpace(link.URL)
|
||||
fmt.Println("✓ Tidal URL found")
|
||||
fmt.Println("Tidal URL found")
|
||||
}
|
||||
|
||||
if link, ok := resp.LinksByPlatform["amazonMusic"]; ok && link.URL != "" && links.AmazonURL == "" {
|
||||
links.AmazonURL = normalizeAmazonMusicURL(link.URL)
|
||||
fmt.Println("✓ Amazon URL found")
|
||||
fmt.Println("Amazon URL found")
|
||||
}
|
||||
|
||||
if link, ok := resp.LinksByPlatform["deezer"]; ok && link.URL != "" && links.DeezerURL == "" {
|
||||
links.DeezerURL = normalizeDeezerTrackURL(link.URL)
|
||||
fmt.Println("✓ Deezer URL found")
|
||||
fmt.Println("Deezer URL found")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -110,19 +110,19 @@ func assignSongstatsLink(rawLink string, links *resolvedTrackLinks) {
|
||||
case strings.Contains(link, "listen.tidal.com/track"):
|
||||
if links.TidalURL == "" {
|
||||
links.TidalURL = link
|
||||
fmt.Println("✓ Tidal URL found via Songstats")
|
||||
fmt.Println("Tidal URL found via Songstats")
|
||||
}
|
||||
case strings.Contains(link, "music.amazon.com"):
|
||||
if links.AmazonURL == "" {
|
||||
if normalized := normalizeAmazonMusicURL(link); normalized != "" {
|
||||
links.AmazonURL = normalized
|
||||
fmt.Println("✓ Amazon URL found via Songstats")
|
||||
fmt.Println("Amazon URL found via Songstats")
|
||||
}
|
||||
}
|
||||
case strings.Contains(link, "deezer.com"):
|
||||
if links.DeezerURL == "" {
|
||||
links.DeezerURL = normalizeDeezerTrackURL(link)
|
||||
fmt.Println("✓ Deezer URL found via Songstats")
|
||||
fmt.Println("Deezer URL found via Songstats")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ type AlbumInfoMetadata struct {
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Artists string `json:"artists"`
|
||||
Images string `json:"images"`
|
||||
IsExplicit bool `json:"is_explicit,omitempty"`
|
||||
UPC string `json:"upc,omitempty"`
|
||||
Batch string `json:"batch,omitempty"`
|
||||
ArtistID string `json:"artist_id,omitempty"`
|
||||
@@ -162,6 +163,7 @@ type DiscographyAlbumMetadata struct {
|
||||
Artists string `json:"artists"`
|
||||
Images string `json:"images"`
|
||||
ExternalURL string `json:"external_urls"`
|
||||
IsExplicit bool `json:"is_explicit,omitempty"`
|
||||
}
|
||||
|
||||
type ArtistDiscographyPayload struct {
|
||||
@@ -1104,12 +1106,21 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse, callback
|
||||
break
|
||||
}
|
||||
|
||||
albumExplicit := false
|
||||
for _, track := range raw.Tracks {
|
||||
if track.IsExplicit {
|
||||
albumExplicit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
info := AlbumInfoMetadata{
|
||||
TotalTracks: raw.Count,
|
||||
Name: raw.Name,
|
||||
ReleaseDate: raw.ReleaseDate,
|
||||
Artists: raw.Artists,
|
||||
Images: raw.Cover,
|
||||
IsExplicit: albumExplicit,
|
||||
UPC: raw.UPC,
|
||||
ArtistID: artistID,
|
||||
ArtistURL: artistURL,
|
||||
@@ -1276,8 +1287,10 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
||||
allTracks := make([]AlbumTrackMetadata, 0)
|
||||
|
||||
type fetchResult struct {
|
||||
tracks []AlbumTrackMetadata
|
||||
err error
|
||||
albumID string
|
||||
tracks []AlbumTrackMetadata
|
||||
isExplicit bool
|
||||
err error
|
||||
}
|
||||
|
||||
resultsChan := make(chan fetchResult, len(raw.Discography.All))
|
||||
@@ -1318,7 +1331,7 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
resultsChan <- fetchResult{err: ctx.Err()}
|
||||
resultsChan <- fetchResult{albumID: albumID, err: ctx.Err()}
|
||||
return
|
||||
default:
|
||||
}
|
||||
@@ -1326,14 +1339,18 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
||||
albumData, err := c.fetchAlbumWithClient(ctx, sharedClient, albumID, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("Error getting tracks for album %s: %v\n", albumName, err)
|
||||
resultsChan <- fetchResult{tracks: []AlbumTrackMetadata{}}
|
||||
resultsChan <- fetchResult{albumID: albumID, tracks: []AlbumTrackMetadata{}}
|
||||
return
|
||||
}
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(albumData.Tracks))
|
||||
albumExplicit := false
|
||||
for idx, tr := range albumData.Tracks {
|
||||
durationMS := parseDuration(tr.Duration)
|
||||
trackNumber := idx + 1
|
||||
if tr.IsExplicit {
|
||||
albumExplicit = true
|
||||
}
|
||||
|
||||
var artistID, artistURL string
|
||||
if len(tr.ArtistIds) > 0 {
|
||||
@@ -1377,7 +1394,7 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
||||
if callback != nil {
|
||||
callback(tracks)
|
||||
}
|
||||
resultsChan <- fetchResult{tracks: tracks}
|
||||
resultsChan <- fetchResult{albumID: albumID, tracks: tracks, isExplicit: albumExplicit}
|
||||
}(alb.ID, alb.Name)
|
||||
}
|
||||
|
||||
@@ -1386,6 +1403,12 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
||||
if res.err != nil {
|
||||
return nil, res.err
|
||||
}
|
||||
for albumIndex := range albumList {
|
||||
if albumList[albumIndex].ID == res.albumID {
|
||||
albumList[albumIndex].IsExplicit = res.isExplicit
|
||||
break
|
||||
}
|
||||
}
|
||||
allTracks = append(allTracks, res.tracks...)
|
||||
}
|
||||
|
||||
|
||||
+192
-50
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -47,15 +48,154 @@ type TidalBTSManifest struct {
|
||||
URLs []string `json:"urls"`
|
||||
}
|
||||
|
||||
func NewTidalDownloader(apiURL string) *TidalDownloader {
|
||||
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||
if apiURL == "" {
|
||||
apis, err := GetRotatedTidalAPIList()
|
||||
if err == nil && len(apis) > 0 {
|
||||
apiURL = apis[0]
|
||||
func getConfiguredTidalAPIAttemptList() ([]string, error) {
|
||||
customAPI := GetCustomTidalAPISetting()
|
||||
if customAPI == "" {
|
||||
return nil, fmt.Errorf("no configured custom tidal api instance")
|
||||
}
|
||||
return []string{customAPI}, nil
|
||||
}
|
||||
|
||||
func buildTidalOutputPath(outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyTrackNumber, spotifyDiscNumber int, isrcOverride string, useFirstArtistOnly bool) (string, bool, error) {
|
||||
if outputDir != "." {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return "", false, fmt.Errorf("directory error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
artistNameForFile := sanitizeFilename(spotifyArtistName)
|
||||
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
|
||||
if useFirstArtistOnly {
|
||||
artistNameForFile = sanitizeFilename(GetFirstArtist(spotifyArtistName))
|
||||
albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
|
||||
}
|
||||
|
||||
trackTitleForFile := sanitizeFilename(spotifyTrackName)
|
||||
albumTitleForFile := sanitizeFilename(spotifyAlbumName)
|
||||
|
||||
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrcOverride)
|
||||
outputFilename := filepath.Join(outputDir, filename)
|
||||
|
||||
outputFilename, alreadyExists := ResolveOutputPathForDownload(outputFilename, GetRedownloadWithSuffixSetting())
|
||||
return outputFilename, alreadyExists, nil
|
||||
}
|
||||
|
||||
func finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useSingleGenre bool, embedGenre bool) {
|
||||
trackTitle := spotifyTrackName
|
||||
artistName := spotifyArtistName
|
||||
albumTitle := spotifyAlbumName
|
||||
|
||||
type mbResult struct {
|
||||
ISRC string
|
||||
Metadata Metadata
|
||||
}
|
||||
|
||||
metaChan := make(chan mbResult, 1)
|
||||
if embedGenre && spotifyURL != "" {
|
||||
go func() {
|
||||
res := mbResult{}
|
||||
var isrc string
|
||||
parts := strings.Split(spotifyURL, "/")
|
||||
if len(parts) > 0 {
|
||||
sID := strings.Split(parts[len(parts)-1], "?")[0]
|
||||
if sID != "" {
|
||||
client := NewSongLinkClient()
|
||||
if val, err := client.GetISRC(sID); err == nil {
|
||||
isrc = val
|
||||
}
|
||||
}
|
||||
}
|
||||
res.ISRC = isrc
|
||||
if isrc != "" {
|
||||
if ShouldSkipMusicBrainzMetadataFetch() {
|
||||
fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.")
|
||||
} else {
|
||||
fmt.Println("Fetching MusicBrainz metadata...")
|
||||
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil {
|
||||
res.Metadata = fetchedMeta
|
||||
fmt.Println("MusicBrainz metadata fetched")
|
||||
} else {
|
||||
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
metaChan <- res
|
||||
}()
|
||||
} else {
|
||||
close(metaChan)
|
||||
}
|
||||
|
||||
isrc := strings.TrimSpace(isrcOverride)
|
||||
var mbMeta Metadata
|
||||
if spotifyURL != "" {
|
||||
result := <-metaChan
|
||||
if isrc == "" {
|
||||
isrc = result.ISRC
|
||||
}
|
||||
mbMeta = result.Metadata
|
||||
}
|
||||
|
||||
upc := ""
|
||||
if spotifyURL != "" {
|
||||
if identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyURL); err == nil || identifiers.ISRC != "" || identifiers.UPC != "" {
|
||||
if strings.TrimSpace(isrc) == "" && strings.TrimSpace(identifiers.ISRC) != "" {
|
||||
isrc = strings.TrimSpace(identifiers.ISRC)
|
||||
}
|
||||
upc = strings.TrimSpace(identifiers.UPC)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Adding metadata...")
|
||||
|
||||
coverPath := ""
|
||||
if spotifyCoverURL != "" {
|
||||
coverPath = outputFilename + ".cover.jpg"
|
||||
coverClient := NewCoverClient()
|
||||
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
|
||||
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
|
||||
coverPath = ""
|
||||
} else {
|
||||
defer os.Remove(coverPath)
|
||||
fmt.Println("Spotify cover downloaded")
|
||||
}
|
||||
}
|
||||
|
||||
trackNumberToEmbed := spotifyTrackNumber
|
||||
if trackNumberToEmbed == 0 {
|
||||
trackNumberToEmbed = 1
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
Title: trackTitle,
|
||||
Artist: artistName,
|
||||
Album: albumTitle,
|
||||
AlbumArtist: spotifyAlbumArtist,
|
||||
Date: spotifyReleaseDate,
|
||||
TrackNumber: trackNumberToEmbed,
|
||||
TotalTracks: spotifyTotalTracks,
|
||||
DiscNumber: spotifyDiscNumber,
|
||||
TotalDiscs: spotifyTotalDiscs,
|
||||
URL: spotifyURL,
|
||||
Comment: spotifyURL,
|
||||
Copyright: spotifyCopyright,
|
||||
Publisher: spotifyPublisher,
|
||||
Composer: spotifyComposer,
|
||||
Separator: metadataSeparator,
|
||||
Description: "https://github.com/spotbye/SpotiFLAC",
|
||||
ISRC: isrc,
|
||||
UPC: upc,
|
||||
Genre: mbMeta.Genre,
|
||||
}
|
||||
|
||||
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
|
||||
fmt.Printf("Tagging failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("Metadata saved")
|
||||
}
|
||||
}
|
||||
|
||||
func NewTidalDownloader(apiURL string) *TidalDownloader {
|
||||
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||
return &TidalDownloader{
|
||||
client: &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
@@ -67,7 +207,7 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
||||
apis, err := GetRotatedTidalAPIList()
|
||||
apis, err := getConfiguredTidalAPIAttemptList()
|
||||
if err == nil && len(apis) > 0 {
|
||||
return apis, nil
|
||||
}
|
||||
@@ -112,37 +252,41 @@ func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
|
||||
|
||||
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
||||
fmt.Println("Fetching URL...")
|
||||
if strings.TrimSpace(t.apiURL) == "" {
|
||||
fmt.Println("No custom Tidal instance configured, using community tdl-a endpoint")
|
||||
return t.getTidalCommunityDownloadURL(trackID, quality)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality)
|
||||
fmt.Printf("Tidal API URL: %s\n", url)
|
||||
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("✗ failed to create request: %v\n", err)
|
||||
fmt.Printf("failed to create request: %v\n", err)
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("✗ Tidal API request failed: %v\n", err)
|
||||
fmt.Printf("Tidal API request failed: %v\n", err)
|
||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
fmt.Printf("✗ Tidal API returned status code: %d\n", resp.StatusCode)
|
||||
fmt.Printf("Tidal API returned status code: %d\n", resp.StatusCode)
|
||||
return "", fmt.Errorf("API returned status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
fmt.Printf("✗ Failed to read response body: %v\n", err)
|
||||
fmt.Printf("Failed to read response body: %v\n", err)
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var v2Response TidalAPIResponseV2
|
||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||
fmt.Println("✓ Tidal manifest found (v2 API)")
|
||||
fmt.Println("Tidal manifest found (v2 API)")
|
||||
return "MANIFEST:" + v2Response.Data.Manifest, nil
|
||||
}
|
||||
|
||||
@@ -153,30 +297,30 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
}
|
||||
fmt.Printf("✗ Failed to decode Tidal API response: %v (response: %s)\n", err, bodyStr)
|
||||
fmt.Printf("Failed to decode Tidal API response: %v (response: %s)\n", err, bodyStr)
|
||||
return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
|
||||
}
|
||||
|
||||
if len(apiResponses) == 0 {
|
||||
fmt.Println("✗ Tidal API returned empty response")
|
||||
fmt.Println("Tidal API returned empty response")
|
||||
return "", fmt.Errorf("no download URL in response")
|
||||
}
|
||||
|
||||
for _, item := range apiResponses {
|
||||
if item.OriginalTrackURL != "" {
|
||||
fmt.Println("✓ Tidal download URL found")
|
||||
fmt.Println("Tidal download URL found")
|
||||
return item.OriginalTrackURL, nil
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("✗ No valid download URL in Tidal API response")
|
||||
fmt.Println("No valid download URL in Tidal API response")
|
||||
return "", fmt.Errorf("download URL not found in response")
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) DownloadFile(url, filepath string) error {
|
||||
func (t *TidalDownloader) DownloadFile(url, filepath string, quality string) error {
|
||||
|
||||
if strings.HasPrefix(url, "MANIFEST:") {
|
||||
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath)
|
||||
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath, quality)
|
||||
}
|
||||
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
|
||||
@@ -184,7 +328,8 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error {
|
||||
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 {
|
||||
return fmt.Errorf("failed to download file: %w", err)
|
||||
@@ -213,12 +358,18 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) error {
|
||||
func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string, quality string) error {
|
||||
directURL, initURL, mediaURLs, mimeType, err := parseManifest(manifestB64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse manifest: %w", err)
|
||||
}
|
||||
|
||||
isLosslessRequested := quality == "LOSSLESS" || quality == "HI_RES" || quality == "HI_RES_LOSSLESS"
|
||||
isActualLossless := strings.Contains(strings.ToLower(mimeType), "flac") || mimeType == ""
|
||||
if isLosslessRequested && !isActualLossless {
|
||||
return fmt.Errorf("requested %s quality but Tidal provided lossy format (%s). Aborting download", quality, mimeType)
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 120 * time.Second,
|
||||
}
|
||||
@@ -421,8 +572,11 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
||||
|
||||
downloadURL, err := t.GetDownloadURL(trackID, quality)
|
||||
if err != nil {
|
||||
if IsDownloadCancelledError(err) {
|
||||
return outputFilename, err
|
||||
}
|
||||
if isTidalHiResQuality(quality) && allowFallback {
|
||||
fmt.Println("⚠ HI_RES unavailable/failed, falling back to LOSSLESS...")
|
||||
fmt.Println("HI_RES unavailable/failed, falling back to LOSSLESS...")
|
||||
downloadURL, err = t.GetDownloadURL(trackID, "LOSSLESS")
|
||||
if err != nil {
|
||||
return outputFilename, fmt.Errorf("failed to get download URL (HI_RES & LOSSLESS both failed): %w", err)
|
||||
@@ -433,20 +587,15 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
||||
}
|
||||
|
||||
fmt.Printf("Downloading to: %s\n", outputFilename)
|
||||
if err := t.DownloadFile(downloadURL, outputFilename); err != nil {
|
||||
if err := t.DownloadFile(downloadURL, outputFilename, quality); err != nil {
|
||||
cleanupTidalDownloadArtifacts(outputFilename)
|
||||
return outputFilename, err
|
||||
}
|
||||
if t.apiURL != "" {
|
||||
if err := RememberTidalAPIUsage(t.apiURL); err != nil {
|
||||
fmt.Printf("Warning: failed to persist last used Tidal API: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre)
|
||||
|
||||
fmt.Println("Done")
|
||||
fmt.Println("✓ Downloaded successfully from Tidal")
|
||||
fmt.Println("Downloaded successfully from Tidal")
|
||||
return outputFilename, nil
|
||||
}
|
||||
|
||||
@@ -477,12 +626,12 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
||||
cleanupTidalDownloadArtifacts(outputFilename)
|
||||
return outputFilename, err
|
||||
}
|
||||
fmt.Printf("✓ Downloaded using API: %s\n", successAPI)
|
||||
fmt.Printf("Downloaded using API: %s\n", successAPI)
|
||||
|
||||
finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre)
|
||||
|
||||
fmt.Println("Done")
|
||||
fmt.Println("✓ Downloaded successfully from Tidal")
|
||||
fmt.Println("Downloaded successfully from Tidal")
|
||||
return outputFilename, nil
|
||||
}
|
||||
|
||||
@@ -493,7 +642,7 @@ func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameF
|
||||
return "", fmt.Errorf("songlink/songstats couldn't find Tidal URL: %w", err)
|
||||
}
|
||||
|
||||
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
|
||||
return t.DownloadByURL(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
|
||||
}
|
||||
|
||||
type SegmentTemplate struct {
|
||||
@@ -550,10 +699,12 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
||||
|
||||
var mpd MPD
|
||||
var segTemplate *SegmentTemplate
|
||||
var dashMimeType string
|
||||
|
||||
if err := xml.Unmarshal(manifestBytes, &mpd); err == nil {
|
||||
var selectedBandwidth int
|
||||
var selectedCodecs string
|
||||
var selectedMimeType string
|
||||
|
||||
for _, as := range mpd.Period.AdaptationSets {
|
||||
|
||||
@@ -562,6 +713,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
||||
if segTemplate == nil {
|
||||
segTemplate = as.SegmentTemplate
|
||||
selectedCodecs = as.Codecs
|
||||
selectedMimeType = as.MimeType
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,6 +728,8 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
||||
} else {
|
||||
selectedCodecs = as.Codecs
|
||||
}
|
||||
|
||||
selectedMimeType = as.MimeType
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -583,6 +737,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
||||
|
||||
if selectedBandwidth > 0 {
|
||||
fmt.Printf("Selected stream: Codec=%s, Bandwidth=%d bps\n", selectedCodecs, selectedBandwidth)
|
||||
dashMimeType = fmt.Sprintf("%s; codecs=\"%s\"", selectedMimeType, selectedCodecs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -608,7 +763,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
||||
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
||||
mediaURLs = append(mediaURLs, mediaURL)
|
||||
}
|
||||
return "", initURL, mediaURLs, "", nil
|
||||
return "", initURL, mediaURLs, dashMimeType, nil
|
||||
}
|
||||
|
||||
fmt.Println("Using regex fallback for DASH manifest...")
|
||||
@@ -655,7 +810,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
||||
mediaURLs = append(mediaURLs, mediaURL)
|
||||
}
|
||||
|
||||
return "", initURL, mediaURLs, "", nil
|
||||
return "", initURL, mediaURLs, dashMimeType, nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename string, quality string, allowFallback bool) (string, error) {
|
||||
@@ -667,7 +822,7 @@ func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename
|
||||
var lastErr error
|
||||
for idx, candidateQuality := range qualities {
|
||||
if idx > 0 {
|
||||
fmt.Printf("⚠ %s unavailable/failed on all APIs, falling back to %s...\n", quality, candidateQuality)
|
||||
fmt.Printf("%s unavailable/failed on all APIs, falling back to %s...\n", quality, candidateQuality)
|
||||
}
|
||||
|
||||
apiURL, err := t.tryDownloadAcrossTidalAPIs(trackID, outputFilename, candidateQuality, false)
|
||||
@@ -684,7 +839,7 @@ func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilename string, quality string, refreshed bool) (string, error) {
|
||||
apis, err := GetRotatedTidalAPIList()
|
||||
apis, err := getConfiguredTidalAPIAttemptList()
|
||||
if err != nil && len(apis) == 0 {
|
||||
return "", fmt.Errorf("failed to load tidal api list: %w", err)
|
||||
}
|
||||
@@ -706,36 +861,23 @@ func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilena
|
||||
continue
|
||||
}
|
||||
|
||||
if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil {
|
||||
if err := downloader.DownloadFile(downloadURL, outputFilename, quality); err != nil {
|
||||
lastErr = err
|
||||
cleanupTidalDownloadArtifacts(outputFilename)
|
||||
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err))
|
||||
continue
|
||||
}
|
||||
|
||||
if err := RememberTidalAPIUsage(apiURL); err != nil {
|
||||
fmt.Printf("Warning: failed to persist last used Tidal API: %v\n", err)
|
||||
}
|
||||
|
||||
return apiURL, nil
|
||||
}
|
||||
|
||||
if !refreshed {
|
||||
if _, refreshErr := RefreshTidalAPIList(true); refreshErr != nil {
|
||||
errors = append(errors, fmt.Sprintf("gist refresh failed: %v", refreshErr))
|
||||
} else {
|
||||
fmt.Println("All cached Tidal APIs failed, refreshed gist list and retrying...")
|
||||
return t.tryDownloadAcrossTidalAPIs(trackID, outputFilename, quality, true)
|
||||
}
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("all tidal apis failed")
|
||||
}
|
||||
|
||||
fmt.Println("All Tidal APIs failed:")
|
||||
for _, item := range errors {
|
||||
fmt.Printf(" ✗ %s\n", item)
|
||||
fmt.Printf(" %s\n", item)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("all tidal apis failed for quality %s: %w", quality, lastErr)
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const tidalAltDownloadAPIBaseURL = "https://tidal.spotbye.qzz.io/get"
|
||||
|
||||
type TidalAltAPIResponse struct {
|
||||
Title string `json:"title"`
|
||||
Link string `json:"link"`
|
||||
}
|
||||
|
||||
func buildTidalOutputPath(outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyTrackNumber, spotifyDiscNumber int, isrcOverride string, useFirstArtistOnly bool) (string, bool, error) {
|
||||
if outputDir != "." {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return "", false, fmt.Errorf("directory error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
artistNameForFile := sanitizeFilename(spotifyArtistName)
|
||||
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
|
||||
if useFirstArtistOnly {
|
||||
artistNameForFile = sanitizeFilename(GetFirstArtist(spotifyArtistName))
|
||||
albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
|
||||
}
|
||||
|
||||
trackTitleForFile := sanitizeFilename(spotifyTrackName)
|
||||
albumTitleForFile := sanitizeFilename(spotifyAlbumName)
|
||||
|
||||
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrcOverride)
|
||||
outputFilename := filepath.Join(outputDir, filename)
|
||||
|
||||
outputFilename, alreadyExists := ResolveOutputPathForDownload(outputFilename, GetRedownloadWithSuffixSetting())
|
||||
return outputFilename, alreadyExists, nil
|
||||
}
|
||||
|
||||
func finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useSingleGenre bool, embedGenre bool) {
|
||||
trackTitle := spotifyTrackName
|
||||
artistName := spotifyArtistName
|
||||
albumTitle := spotifyAlbumName
|
||||
|
||||
type mbResult struct {
|
||||
ISRC string
|
||||
Metadata Metadata
|
||||
}
|
||||
|
||||
metaChan := make(chan mbResult, 1)
|
||||
if embedGenre && spotifyURL != "" {
|
||||
go func() {
|
||||
res := mbResult{}
|
||||
var isrc string
|
||||
parts := strings.Split(spotifyURL, "/")
|
||||
if len(parts) > 0 {
|
||||
sID := strings.Split(parts[len(parts)-1], "?")[0]
|
||||
if sID != "" {
|
||||
client := NewSongLinkClient()
|
||||
if val, err := client.GetISRC(sID); err == nil {
|
||||
isrc = val
|
||||
}
|
||||
}
|
||||
}
|
||||
res.ISRC = isrc
|
||||
if isrc != "" {
|
||||
if ShouldSkipMusicBrainzMetadataFetch() {
|
||||
fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.")
|
||||
} else {
|
||||
fmt.Println("Fetching MusicBrainz metadata...")
|
||||
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil {
|
||||
res.Metadata = fetchedMeta
|
||||
fmt.Println("✓ MusicBrainz metadata fetched")
|
||||
} else {
|
||||
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
metaChan <- res
|
||||
}()
|
||||
} else {
|
||||
close(metaChan)
|
||||
}
|
||||
|
||||
isrc := strings.TrimSpace(isrcOverride)
|
||||
var mbMeta Metadata
|
||||
if spotifyURL != "" {
|
||||
result := <-metaChan
|
||||
if isrc == "" {
|
||||
isrc = result.ISRC
|
||||
}
|
||||
mbMeta = result.Metadata
|
||||
}
|
||||
|
||||
upc := ""
|
||||
if spotifyURL != "" {
|
||||
if identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyURL); err == nil || identifiers.ISRC != "" || identifiers.UPC != "" {
|
||||
if strings.TrimSpace(isrc) == "" && strings.TrimSpace(identifiers.ISRC) != "" {
|
||||
isrc = strings.TrimSpace(identifiers.ISRC)
|
||||
}
|
||||
upc = strings.TrimSpace(identifiers.UPC)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Adding metadata...")
|
||||
|
||||
coverPath := ""
|
||||
if spotifyCoverURL != "" {
|
||||
coverPath = outputFilename + ".cover.jpg"
|
||||
coverClient := NewCoverClient()
|
||||
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
|
||||
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
|
||||
coverPath = ""
|
||||
} else {
|
||||
defer os.Remove(coverPath)
|
||||
fmt.Println("Spotify cover downloaded")
|
||||
}
|
||||
}
|
||||
|
||||
trackNumberToEmbed := spotifyTrackNumber
|
||||
if trackNumberToEmbed == 0 {
|
||||
trackNumberToEmbed = 1
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
Title: trackTitle,
|
||||
Artist: artistName,
|
||||
Album: albumTitle,
|
||||
AlbumArtist: spotifyAlbumArtist,
|
||||
Date: spotifyReleaseDate,
|
||||
TrackNumber: trackNumberToEmbed,
|
||||
TotalTracks: spotifyTotalTracks,
|
||||
DiscNumber: spotifyDiscNumber,
|
||||
TotalDiscs: spotifyTotalDiscs,
|
||||
URL: spotifyURL,
|
||||
Comment: spotifyURL,
|
||||
Copyright: spotifyCopyright,
|
||||
Publisher: spotifyPublisher,
|
||||
Composer: spotifyComposer,
|
||||
Separator: metadataSeparator,
|
||||
Description: "https://github.com/spotbye/SpotiFLAC",
|
||||
ISRC: isrc,
|
||||
UPC: upc,
|
||||
Genre: mbMeta.Genre,
|
||||
}
|
||||
|
||||
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
|
||||
fmt.Printf("Tagging failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("Metadata saved")
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) GetAltDownloadURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||
spotifyTrackID = strings.TrimSpace(spotifyTrackID)
|
||||
if spotifyTrackID == "" {
|
||||
return "", fmt.Errorf("spotify track ID is required")
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("%s/%s", tidalAltDownloadAPIBaseURL, spotifyTrackID)
|
||||
fmt.Printf("Tidal Alt. API URL: %s\n", apiURL)
|
||||
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create Tidal Alt. request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get Tidal Alt. download URL: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read Tidal Alt. response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
preview := strings.TrimSpace(string(body))
|
||||
if len(preview) > 200 {
|
||||
preview = preview[:200] + "..."
|
||||
}
|
||||
return "", fmt.Errorf("Tidal Alt. returned status %d: %s", resp.StatusCode, preview)
|
||||
}
|
||||
|
||||
var payload TidalAltAPIResponse
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return "", fmt.Errorf("failed to decode Tidal Alt. response: %w", err)
|
||||
}
|
||||
|
||||
downloadURL := strings.TrimSpace(payload.Link)
|
||||
if downloadURL == "" {
|
||||
return "", fmt.Errorf("Tidal Alt. response did not include a download link")
|
||||
}
|
||||
|
||||
fmt.Println("✓ Tidal Alt. download URL found")
|
||||
return downloadURL, nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) DownloadAlt(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||
spotifyTrackID = strings.TrimSpace(spotifyTrackID)
|
||||
if spotifyTrackID == "" {
|
||||
return "", fmt.Errorf("spotify track ID is required for Tidal Alt.")
|
||||
}
|
||||
|
||||
outputFilename, alreadyExists, err := buildTidalOutputPath(outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyTrackNumber, spotifyDiscNumber, isrcOverride, useFirstArtistOnly)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if alreadyExists {
|
||||
fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(mustFileSize(outputFilename))/(1024*1024))
|
||||
return "EXISTS:" + outputFilename, nil
|
||||
}
|
||||
|
||||
fmt.Printf("Using Tidal Alt. for Spotify track: %s\n", spotifyTrackID)
|
||||
|
||||
downloadURL, err := t.GetAltDownloadURLFromSpotify(spotifyTrackID)
|
||||
if err != nil {
|
||||
return outputFilename, err
|
||||
}
|
||||
|
||||
fmt.Printf("Downloading to: %s\n", outputFilename)
|
||||
if err := t.DownloadFile(downloadURL, outputFilename); err != nil {
|
||||
cleanupTidalDownloadArtifacts(outputFilename)
|
||||
return outputFilename, err
|
||||
}
|
||||
|
||||
finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre)
|
||||
|
||||
fmt.Println("Done")
|
||||
fmt.Println("✓ Downloaded successfully from Tidal Alt.")
|
||||
return outputFilename, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
@@ -55,4 +56,4 @@
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
867c45db7982e126a7249d80210f23be
|
||||
8864b4f7b7971b624d1ba25030f2db4e
|
||||
Generated
+3
@@ -32,6 +32,9 @@ importers:
|
||||
'@radix-ui/react-select':
|
||||
specifier: ^2.2.6
|
||||
version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-slider':
|
||||
specifier: ^1.3.6
|
||||
version: 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-slot':
|
||||
specifier: ^1.2.4
|
||||
version: 1.2.4(@types/react@19.2.14)(react@19.2.4)
|
||||
|
||||
@@ -14,10 +14,10 @@ async function generateIcon() {
|
||||
.resize(1024, 1024)
|
||||
.png()
|
||||
.toFile(outputPath);
|
||||
console.log('✓ Icon generated:', outputPath);
|
||||
console.log('Icon generated:', outputPath);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('✗ Failed to generate icon:', error.message);
|
||||
console.error('Failed to generate icon:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
+79
-16
@@ -5,12 +5,14 @@ import { Search, X, ArrowUp } from "lucide-react";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont } from "@/lib/settings";
|
||||
import { applyTheme } from "@/lib/themes";
|
||||
import { openExternal } from "@/lib/utils";
|
||||
import { OpenFolder, CheckFFmpegInstalled, DownloadFFmpeg, GetRecentFetches, SaveRecentFetches } from "../wailsjs/go/main/App";
|
||||
import { EventsOn, EventsOff, Quit } from "../wailsjs/runtime/runtime";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { TitleBar } from "@/components/TitleBar";
|
||||
import { Sidebar, type PageType } from "@/components/Sidebar";
|
||||
import { Header } from "@/components/Header";
|
||||
import { MarkdownLite, extractMarkdownSection } from "@/components/MarkdownLite";
|
||||
import { SearchBar } from "@/components/SearchBar";
|
||||
import { TrackInfo } from "@/components/TrackInfo";
|
||||
import { AlbumInfo } from "@/components/AlbumInfo";
|
||||
@@ -22,17 +24,19 @@ import { AudioAnalysisPage } from "@/components/AudioAnalysisPage";
|
||||
import { AudioConverterPage } from "@/components/AudioConverterPage";
|
||||
import { AudioResamplerPage } from "@/components/AudioResamplerPage";
|
||||
import { FileManagerPage } from "@/components/FileManagerPage";
|
||||
import { LyricsManagerPage } from "@/components/LyricsManagerPage";
|
||||
import { SettingsPage } from "@/components/SettingsPage";
|
||||
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
|
||||
import { AboutPage } from "@/components/AboutPage";
|
||||
import { OtherProjects } from "@/components/OtherProjects";
|
||||
import { HistoryPage } from "@/components/HistoryPage";
|
||||
import { SupportPage } from "@/components/SupportPage";
|
||||
import type { HistoryItem } from "@/components/FetchHistory";
|
||||
import { useDownload } from "@/hooks/useDownload";
|
||||
import { useMetadata } from "@/hooks/useMetadata";
|
||||
import { useLyrics } from "@/hooks/useLyrics";
|
||||
import { useCover } from "@/hooks/useCover";
|
||||
import { useAvailability } from "@/hooks/useAvailability";
|
||||
import { ensureSpotiFLACNextStatusCheckStarted } from "@/lib/api-status";
|
||||
import { ensureApiStatusCheckStarted } from "@/lib/api-status";
|
||||
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
|
||||
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
||||
import { buildPlaylistFolderName } from "@/lib/playlist";
|
||||
@@ -133,6 +137,12 @@ function App() {
|
||||
const [currentListPage, setCurrentListPage] = useState(1);
|
||||
const [hasUpdate, setHasUpdate] = useState(false);
|
||||
const [releaseDate, setReleaseDate] = useState<string | null>(null);
|
||||
const [updateInfo, setUpdateInfo] = useState<{
|
||||
version: string;
|
||||
changelog: string;
|
||||
url: string;
|
||||
} | null>(null);
|
||||
const [showUpdateDialog, setShowUpdateDialog] = useState(false);
|
||||
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
|
||||
const [isSearchMode, setIsSearchMode] = useState(false);
|
||||
const [region, setRegion] = useState(() => localStorage.getItem("spotiflac_region") || "US");
|
||||
@@ -162,7 +172,7 @@ function App() {
|
||||
if (savedSettings) {
|
||||
applyThemeMode(savedSettings.themeMode);
|
||||
applyTheme(savedSettings.theme);
|
||||
applyFont(savedSettings.fontFamily);
|
||||
applyFont(savedSettings.fontFamily, savedSettings.customFonts);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
@@ -170,7 +180,7 @@ function App() {
|
||||
const settings = await loadSettings();
|
||||
applyThemeMode(settings.themeMode);
|
||||
applyTheme(settings.theme);
|
||||
applyFont(settings.fontFamily);
|
||||
applyFont(settings.fontFamily, settings.customFonts);
|
||||
if (!settings.downloadPath) {
|
||||
const settingsWithDefaults = await getSettingsWithDefaults();
|
||||
await saveSettings(settingsWithDefaults);
|
||||
@@ -198,7 +208,7 @@ function App() {
|
||||
};
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
checkForUpdates();
|
||||
ensureSpotiFLACNextStatusCheckStarted();
|
||||
ensureApiStatusCheckStarted();
|
||||
void loadHistory();
|
||||
return () => {
|
||||
mediaQuery.removeEventListener("change", handleChange);
|
||||
@@ -237,14 +247,24 @@ function App() {
|
||||
}, [metadata.metadata]);
|
||||
const checkForUpdates = async () => {
|
||||
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 latestVersion = data.tag_name?.replace(/^v/, "") || "";
|
||||
const rawTag = data.tag_name || "";
|
||||
const latestVersion = rawTag.replace(/^v/, "") || "";
|
||||
if (data.published_at) {
|
||||
setReleaseDate(data.published_at);
|
||||
}
|
||||
if (latestVersion && latestVersion > CURRENT_VERSION) {
|
||||
setHasUpdate(true);
|
||||
setUpdateInfo({
|
||||
version: latestVersion,
|
||||
changelog: extractMarkdownSection(data.body || "", "Changelog"),
|
||||
url: `https://github.com/spotbye/SpotiFLAC/releases/tag/${rawTag}`,
|
||||
});
|
||||
const dismissedVersion = localStorage.getItem("spotiflac_update_dismissed_version");
|
||||
if (dismissedVersion !== latestVersion) {
|
||||
setShowUpdateDialog(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
@@ -362,6 +382,7 @@ function App() {
|
||||
name: track.name,
|
||||
artist: track.artists,
|
||||
image: track.images,
|
||||
is_explicit: track.is_explicit,
|
||||
};
|
||||
}
|
||||
else if ("album_info" in metadata.metadata) {
|
||||
@@ -372,6 +393,7 @@ function App() {
|
||||
name: album_info.name,
|
||||
artist: `${album_info.total_tracks.toLocaleString()} tracks`,
|
||||
image: album_info.images,
|
||||
is_explicit: album_info.is_explicit,
|
||||
};
|
||||
}
|
||||
else if ("playlist_info" in metadata.metadata) {
|
||||
@@ -446,7 +468,7 @@ function App() {
|
||||
}
|
||||
if ("album_info" in metadata.metadata) {
|
||||
const { album_info, track_list } = metadata.metadata;
|
||||
return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
|
||||
return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} downloadRemainingCount={download.downloadRemainingCount} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
|
||||
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
||||
setSpotifyUrl(pendingArtistUrl);
|
||||
const artistUrl = await metadata.handleArtistClick(artist);
|
||||
@@ -464,7 +486,7 @@ function App() {
|
||||
const { playlist_info, track_list } = metadata.metadata;
|
||||
const settings = getSettings();
|
||||
const playlistFolderName = buildPlaylistFolderName(playlist_info.owner.name, playlist_info.owner.display_name, settings.playlistOwnerFolderName);
|
||||
return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlistFolderName, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlistFolderName, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlistFolderName)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlistFolderName)} onDownloadAll={() => download.handleDownloadAll(track_list, playlistFolderName)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlistFolderName)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
|
||||
return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} downloadRemainingCount={download.downloadRemainingCount} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlistFolderName, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlistFolderName, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlistFolderName)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlistFolderName)} onDownloadAll={() => download.handleDownloadAll(track_list, playlistFolderName)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlistFolderName)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
|
||||
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
||||
setSpotifyUrl(pendingArtistUrl);
|
||||
const artistUrl = await metadata.handleArtistClick(artist);
|
||||
@@ -480,7 +502,7 @@ function App() {
|
||||
}
|
||||
if ("artist_info" in metadata.metadata) {
|
||||
const { artist_info, album_list, track_list } = metadata.metadata;
|
||||
return (<ArtistInfo artistInfo={artist_info} albumList={album_list} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
|
||||
return (<ArtistInfo artistInfo={artist_info} albumList={album_list} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} downloadRemainingCount={download.downloadRemainingCount} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
|
||||
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
||||
setSpotifyUrl(pendingArtistUrl);
|
||||
const artistUrl = await metadata.handleArtistClick(artist);
|
||||
@@ -512,7 +534,7 @@ function App() {
|
||||
const savedSettings = getSettings();
|
||||
applyThemeMode(savedSettings.themeMode);
|
||||
applyTheme(savedSettings.theme);
|
||||
applyFont(savedSettings.fontFamily);
|
||||
applyFont(savedSettings.fontFamily, savedSettings.customFonts);
|
||||
if (pendingPageChange) {
|
||||
setCurrentPage(pendingPageChange);
|
||||
setPendingPageChange(null);
|
||||
@@ -528,8 +550,10 @@ function App() {
|
||||
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn}/>;
|
||||
case "debug":
|
||||
return <DebugLoggerPage />;
|
||||
case "about":
|
||||
return <AboutPage />;
|
||||
case "projects":
|
||||
return <OtherProjects />;
|
||||
case "support":
|
||||
return <SupportPage />;
|
||||
case "history":
|
||||
return <HistoryPage onHistorySelect={(cachedData) => {
|
||||
metadata.loadFromCache(cachedData);
|
||||
@@ -543,6 +567,8 @@ function App() {
|
||||
return <AudioResamplerPage />;
|
||||
case "file-manager":
|
||||
return <FileManagerPage />;
|
||||
case "lyrics-manager":
|
||||
return <LyricsManagerPage />;
|
||||
default:
|
||||
return (<>
|
||||
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} releaseDate={releaseDate}/>
|
||||
@@ -551,7 +577,7 @@ function App() {
|
||||
|
||||
|
||||
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
|
||||
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
||||
<DialogContent className="sm:max-w-106.25 p-6 [&>button]:hidden">
|
||||
<div className="absolute right-4 top-4">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowAlbumDialog(false)}>
|
||||
<X className="h-4 w-4"/>
|
||||
@@ -623,8 +649,45 @@ function App() {
|
||||
</Button>)}
|
||||
|
||||
|
||||
<Dialog open={showUpdateDialog} onOpenChange={setShowUpdateDialog}>
|
||||
<DialogContent className="sm:max-w-125 [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update Available</DialogTitle>
|
||||
<DialogDescription>
|
||||
A new version{updateInfo ? ` (v${updateInfo.version})` : ""} is available. You're on v{CURRENT_VERSION}.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{updateInfo?.changelog ? (<div className="max-h-72 overflow-y-auto rounded-md border bg-muted/40 p-3 custom-scrollbar">
|
||||
<MarkdownLite content={updateInfo.changelog}/>
|
||||
</div>) : (<p className="text-sm text-muted-foreground">No changelog provided for this release.</p>)}
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
<Button variant="ghost" onClick={() => {
|
||||
if (updateInfo) {
|
||||
localStorage.setItem("spotiflac_update_dismissed_version", updateInfo.version);
|
||||
}
|
||||
setShowUpdateDialog(false);
|
||||
}}>
|
||||
Don't Show
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setShowUpdateDialog(false)}>
|
||||
Download Later
|
||||
</Button>
|
||||
<Button onClick={() => {
|
||||
if (updateInfo) {
|
||||
openExternal(updateInfo.url);
|
||||
}
|
||||
setShowUpdateDialog(false);
|
||||
}}>
|
||||
Download Now
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showUnsavedChangesDialog} onOpenChange={setShowUnsavedChangesDialog}>
|
||||
<DialogContent className="sm:max-w-[425px] [&>button]:hidden">
|
||||
<DialogContent className="sm:max-w-106.25 [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Unsaved Changes</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -671,7 +734,7 @@ function App() {
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isFFmpegInstalled === false} onOpenChange={() => { }}>
|
||||
<DialogContent className="max-w-[450px] [&>button]:hidden p-6 gap-5">
|
||||
<DialogContent className="max-w-112.5 [&>button]:hidden p-6 gap-5">
|
||||
<DialogHeader className="space-y-2">
|
||||
<DialogTitle className="text-lg font-bold tracking-tight">
|
||||
FFmpeg Required
|
||||
|
||||
@@ -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 { joinPath, sanitizePath } from "@/lib/utils";
|
||||
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";
|
||||
interface AlbumInfoProps {
|
||||
albumInfo: {
|
||||
@@ -21,6 +21,7 @@ interface AlbumInfoProps {
|
||||
images: string;
|
||||
release_date: string;
|
||||
total_tracks: number;
|
||||
is_explicit?: boolean;
|
||||
artist_id?: string;
|
||||
artist_url?: string;
|
||||
};
|
||||
@@ -35,6 +36,7 @@ interface AlbumInfoProps {
|
||||
isDownloading: boolean;
|
||||
bulkDownloadType: "all" | "selected" | null;
|
||||
downloadProgress: number;
|
||||
downloadRemainingCount: number;
|
||||
currentDownloadInfo: {
|
||||
name: string;
|
||||
artists: string;
|
||||
@@ -77,7 +79,7 @@ interface AlbumInfoProps {
|
||||
onTrackClick?: (track: TrackMetadata) => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) {
|
||||
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, downloadRemainingCount, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) {
|
||||
const settings = getSettings();
|
||||
const albumArtistNames = splitArtistNames(albumInfo.artists);
|
||||
const artistSeparator = albumInfo.artists.includes(";") ? "; " : ", ";
|
||||
@@ -205,18 +207,21 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
|
||||
</div>)}
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">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>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium">
|
||||
{clickableAlbumArtists.length > 0 ? clickableAlbumArtists.map((artist, index) => (<span key={`${artist.id || artist.name}-${index}`}>
|
||||
{onArtistClick && artist.external_urls ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
|
||||
{clickableAlbumArtists.length > 0 ? clickableAlbumArtists.map((artist, index) => (<span key={getClickableArtistKey(artist)}>
|
||||
{onArtistClick && artist.external_urls ? (<button type="button" className="cursor-pointer rounded-sm bg-transparent p-0 text-inherit hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60" onClick={() => onArtistClick({
|
||||
id: artist.id,
|
||||
name: artist.name,
|
||||
external_urls: artist.external_urls,
|
||||
})}>
|
||||
{artist.name}
|
||||
</span>) : (artist.name)}
|
||||
</button>) : (artist.name)}
|
||||
{index < clickableAlbumArtists.length - 1 && artistSeparator}
|
||||
</span>)) : albumInfo.artists}
|
||||
</span>
|
||||
@@ -270,7 +275,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
</div>
|
||||
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||
{isDownloading && (<DownloadProgress progress={downloadProgress} remainingCount={downloadRemainingCount} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SearchCheck, CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon, MusicBrainzIcon, AppleMusicIcon, DeezerIcon } from "./PlatformIcons";
|
||||
import { PlugZap, CheckCircle2, Loader2, Wrench, Server } from "lucide-react";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon, AppleMusicIcon, DeezerIcon } from "./PlatformIcons";
|
||||
import { useApiStatus } from "@/hooks/useApiStatus";
|
||||
import { SPOTIFLAC_NEXT_SOURCES } from "@/lib/api-status";
|
||||
function renderStatusIcon(status: "checking" | "online" | "offline" | "idle") {
|
||||
import { openExternal } from "@/lib/utils";
|
||||
function renderStatusIndicator(status: "checking" | "online" | "offline" | "idle") {
|
||||
if (status === "online") {
|
||||
return <CheckCircle2 className="h-5 w-5 text-emerald-500"/>;
|
||||
}
|
||||
if (status === "offline") {
|
||||
return <XCircle className="h-5 w-5 text-destructive"/>;
|
||||
return <Wrench className="h-4 w-4 text-amber-600 dark:text-amber-400"/>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -19,9 +20,6 @@ function renderPlatformIcon(type: string) {
|
||||
if (type === "amazon") {
|
||||
return <AmazonIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
||||
}
|
||||
if (type === "musicbrainz") {
|
||||
return <MusicBrainzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
||||
}
|
||||
if (type === "deezer") {
|
||||
return <DeezerIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
||||
}
|
||||
@@ -31,27 +29,41 @@ function renderPlatformIcon(type: string) {
|
||||
return <QobuzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
||||
}
|
||||
export function ApiStatusTab() {
|
||||
const { sources, statuses, nextStatuses, checkingSources, checkOne } = useApiStatus();
|
||||
const { sources, statuses, nextStatuses, checkingSources, checkAllCurrent, checkAllNext } = useApiStatus();
|
||||
const isCheckingCurrent = sources.some((source) => checkingSources[source.id] === true);
|
||||
const isCheckingNext = SPOTIFLAC_NEXT_SOURCES.some((source) => nextStatuses[source.id] === "checking");
|
||||
const isChecking = isCheckingCurrent || isCheckingNext;
|
||||
const checkAll = () => {
|
||||
void checkAllCurrent();
|
||||
void checkAllNext();
|
||||
};
|
||||
return (<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Services</h3>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC</h3>
|
||||
<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">
|
||||
{sources.map((source) => {
|
||||
const status = statuses[source.id] || "idle";
|
||||
const isChecking = checkingSources[source.id] === true;
|
||||
return (<div key={source.id} className="space-y-4 p-4 border rounded-lg bg-card text-card-foreground shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{renderPlatformIcon(source.type)}
|
||||
<p className="font-medium leading-none">{source.name}</p>
|
||||
</div>
|
||||
<div className="flex items-center">{renderStatusIcon(status)}</div>
|
||||
<div className="flex items-center">{renderStatusIndicator(status)}</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => void checkOne(source.id)} disabled={isChecking} className="w-full gap-2">
|
||||
{isChecking ? <Loader2 className="h-4 w-4 animate-spin"/> : <SearchCheck className="h-4 w-4"/>}
|
||||
Check
|
||||
</Button>
|
||||
</div>);
|
||||
})}
|
||||
</div>
|
||||
@@ -60,7 +72,7 @@ export function ApiStatusTab() {
|
||||
<div className="border-t"/>
|
||||
|
||||
<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">
|
||||
{SPOTIFLAC_NEXT_SOURCES.map((source) => {
|
||||
@@ -70,7 +82,7 @@ export function ApiStatusTab() {
|
||||
{renderPlatformIcon(source.id)}
|
||||
<p className="font-medium leading-none">{source.name}</p>
|
||||
</div>
|
||||
<div className="flex items-center">{renderStatusIcon(status)}</div>
|
||||
<div className="flex items-center">{renderStatusIndicator(status)}</div>
|
||||
</div>);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -36,6 +36,7 @@ interface ArtistInfoProps {
|
||||
album_type: string;
|
||||
external_urls: string;
|
||||
total_tracks?: number;
|
||||
is_explicit?: boolean;
|
||||
}>;
|
||||
trackList: TrackMetadata[];
|
||||
searchQuery: string;
|
||||
@@ -48,6 +49,7 @@ interface ArtistInfoProps {
|
||||
isDownloading: boolean;
|
||||
bulkDownloadType: "all" | "selected" | null;
|
||||
downloadProgress: number;
|
||||
downloadRemainingCount: number;
|
||||
currentDownloadInfo: {
|
||||
name: string;
|
||||
artists: string;
|
||||
@@ -95,7 +97,7 @@ interface ArtistInfoProps {
|
||||
onTrackClick?: (track: TrackMetadata) => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onAlbumClick, onArtistClick, onPageChange, onTrackClick, onBack, }: ArtistInfoProps) {
|
||||
export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, downloadRemainingCount, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onAlbumClick, onArtistClick, onPageChange, onTrackClick, onBack, }: ArtistInfoProps) {
|
||||
const [downloadingHeader, setDownloadingHeader] = useState(false);
|
||||
const [downloadingAvatar, setDownloadingAvatar] = useState(false);
|
||||
const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null);
|
||||
@@ -325,7 +327,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
||||
{artistInfo.header ? (<>
|
||||
<div className="relative w-full h-64 bg-cover bg-center">
|
||||
<div className="absolute inset-0 bg-cover bg-center" style={{ backgroundImage: `url(${artistInfo.header})` }}/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent"/>
|
||||
<div className="absolute inset-0 bg-linear-to-t from-black via-black/50 to-transparent"/>
|
||||
{onBack && (<div className="absolute top-4 right-4 z-10">
|
||||
<Button variant="ghost" size="icon" onClick={onBack} className="text-white hover:bg-white/20 hover:text-white">
|
||||
<XCircle className="h-5 w-5"/>
|
||||
@@ -474,7 +476,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{artistInfo.gallery!.map((imageUrl, index) => (<div key={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">
|
||||
<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">
|
||||
@@ -536,7 +538,10 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
||||
</span>
|
||||
</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">
|
||||
<span>{album.release_date?.split("-")[0]}</span>
|
||||
{album.total_tracks && (<>
|
||||
@@ -563,7 +568,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
||||
Filter Albums
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px] h-[80vh] flex flex-col">
|
||||
<DialogContent className="sm:max-w-125 h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Select Albums</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -634,7 +639,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
||||
</Tooltip>)}
|
||||
</div>
|
||||
</div>
|
||||
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||
{isDownloading && (<DownloadProgress progress={downloadProgress} remainingCount={downloadRemainingCount} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||
<SearchAndSort searchQuery={searchQuery} sortBy={sortBy} onSearchChange={onSearchChange} onSortChange={onSortChange}/>
|
||||
<TrackList tracks={trackList} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={downloadedTracks} failedTracks={failedTracks} skippedTracks={skippedTracks} downloadingTrack={downloadingTrack} isDownloading={isDownloading} currentPage={currentPage} itemsPerPage={itemsPerPage} showCheckboxes={true} hideAlbumColumn={false} folderName={artistInfo.name} isArtistDiscography={true} downloadedLyrics={downloadedLyrics} failedLyrics={failedLyrics} skippedLyrics={skippedLyrics} downloadingLyricsTrack={downloadingLyricsTrack} checkingAvailabilityTrack={checkingAvailabilityTrack} availabilityMap={availabilityMap} onToggleTrack={onToggleTrack} onToggleSelectAll={onToggleSelectAll} onDownloadTrack={onDownloadTrack} onDownloadLyrics={onDownloadLyrics} onDownloadCover={onDownloadCover} downloadedCovers={downloadedCovers} failedCovers={failedCovers} skippedCovers={skippedCovers} downloadingCoverTrack={downloadingCoverTrack} onCheckAvailability={onCheckAvailability} onPageChange={onPageChange} onAlbumClick={onAlbumClick} onArtistClick={onArtistClick} onTrackClick={onTrackClick}/>
|
||||
</div>)}
|
||||
|
||||
@@ -51,12 +51,12 @@ export function AudioConverterPage() {
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const [outputFormat, setOutputFormat] = useState<"mp3" | "m4a">(() => {
|
||||
const [outputFormat, setOutputFormat] = useState<"mp3" | "m4a" | "wav" | "aiff" | "opus">(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (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;
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,7 @@ export function AudioConverterPage() {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const saveState = useCallback((stateToSave: {
|
||||
files: AudioFile[];
|
||||
outputFormat: "mp3" | "m4a";
|
||||
outputFormat: "mp3" | "m4a" | "wav" | "aiff" | "opus";
|
||||
bitrate: string;
|
||||
m4aCodec: "aac" | "alac";
|
||||
}) => {
|
||||
@@ -116,7 +116,7 @@ export function AudioConverterPage() {
|
||||
if (files.length === 0)
|
||||
return;
|
||||
const allMP3 = files.every((f) => f.format === "mp3");
|
||||
if (allMP3 && outputFormat !== "m4a") {
|
||||
if (allMP3 && outputFormat === "mp3") {
|
||||
setOutputFormat("m4a");
|
||||
}
|
||||
const hasFlac = files.some((f) => f.format === "flac");
|
||||
@@ -375,15 +375,24 @@ export function AudioConverterPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">Format:</Label>
|
||||
<ToggleGroup type="single" variant="outline" value={outputFormat} onValueChange={(value) => {
|
||||
if (value && !isFormatDisabled)
|
||||
setOutputFormat(value as "mp3" | "m4a");
|
||||
}} disabled={isFormatDisabled}>
|
||||
if (value)
|
||||
setOutputFormat(value as "mp3" | "m4a" | "wav" | "aiff" | "opus");
|
||||
}}>
|
||||
{!isFormatDisabled && (<ToggleGroupItem value="mp3" aria-label="MP3">
|
||||
MP3
|
||||
</ToggleGroupItem>)}
|
||||
<ToggleGroupItem value="m4a" aria-label="M4A" disabled={isFormatDisabled}>
|
||||
<ToggleGroupItem value="m4a" aria-label="M4A">
|
||||
M4A
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="opus" aria-label="Opus">
|
||||
Opus
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="wav" aria-label="WAV">
|
||||
WAV
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="aiff" aria-label="AIFF">
|
||||
AIFF
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
@@ -399,7 +408,7 @@ export function AudioConverterPage() {
|
||||
</ToggleGroup>
|
||||
</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>
|
||||
<ToggleGroup type="single" variant="outline" value={bitrate} onValueChange={(value) => {
|
||||
if (value)
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { StopCircle } from "lucide-react";
|
||||
import { StopCircle, Clock } from "lucide-react";
|
||||
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
||||
interface DownloadProgressProps {
|
||||
progress: number;
|
||||
remainingCount?: number;
|
||||
currentTrack: {
|
||||
name: string;
|
||||
artists: string;
|
||||
} | null;
|
||||
onStop: () => void;
|
||||
}
|
||||
export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) {
|
||||
export function DownloadProgress({ progress, remainingCount = 0, currentTrack, onStop }: DownloadProgressProps) {
|
||||
const 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 safeRemainingCount = Math.max(0, remainingCount);
|
||||
const remainingLabel = `${safeRemainingCount.toLocaleString()} ${safeRemainingCount === 1 ? "track" : "tracks"} left`;
|
||||
return (<div className="w-full space-y-2 mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress value={clampedProgress} className="h-2 flex-1"/>
|
||||
@@ -19,11 +26,14 @@ export function DownloadProgress({ progress, currentTrack, onStop }: DownloadPro
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{clampedProgress}% -{" "}
|
||||
{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} -{" "}
|
||||
{currentTrack
|
||||
? `${currentTrack.name} - ${currentTrack.artists}`
|
||||
: "Preparing download..."}
|
||||
</p>
|
||||
? `${currentTrack.name} - ${currentTrack.artists}`
|
||||
: "Preparing download..."}
|
||||
</p>)}
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface HistoryItem {
|
||||
name: string;
|
||||
artist: string;
|
||||
image: string;
|
||||
is_explicit?: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
interface FetchHistoryProps {
|
||||
@@ -75,9 +76,12 @@ export function FetchHistory({ history, onSelect, onRemove }: FetchHistoryProps)
|
||||
</div>)}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs font-medium truncate" title={item.name}>
|
||||
{item.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
{item.is_explicit ? <span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded bg-red-600 text-[9px] font-bold text-white" title="Explicit">E</span> : null}
|
||||
<p className="min-w-0 text-xs font-medium truncate" title={item.name}>
|
||||
{item.name}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate" title={item.artist}>
|
||||
{item.artist}
|
||||
</p>
|
||||
|
||||
@@ -11,9 +11,13 @@ export function Header({ version, hasUpdate, releaseDate }: HeaderProps) {
|
||||
return (<div className="relative">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<img src="/icon.svg" alt="SpotiFLAC" className="w-12 h-12 cursor-pointer" onClick={() => window.location.reload()}/>
|
||||
<h1 className="text-4xl font-bold cursor-pointer" onClick={() => window.location.reload()}>
|
||||
SpotiFLAC
|
||||
<button type="button" className="cursor-pointer rounded-sm border-0 bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60" onClick={() => window.location.reload()} aria-label="Reload SpotiFLAC">
|
||||
<img src="/icon.svg" alt="" className="w-12 h-12"/>
|
||||
</button>
|
||||
<h1 className="text-4xl font-bold">
|
||||
<button type="button" className="cursor-pointer rounded-sm border-0 bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60" onClick={() => window.location.reload()}>
|
||||
SpotiFLAC
|
||||
</button>
|
||||
</h1>
|
||||
<div className="relative">
|
||||
<Tooltip>
|
||||
|
||||
@@ -9,7 +9,8 @@ import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, Pagi
|
||||
import { GetDownloadHistory, ClearDownloadHistory, GetPreviewURL, GetFetchHistory, DeleteDownloadHistoryItem, DeleteFetchHistoryItem, ClearFetchHistoryByType } from "../../wailsjs/go/main/App";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { openExternal } from "@/lib/utils";
|
||||
import { SPOTIFY_PREVIEW_VOLUME } from "@/lib/preview";
|
||||
import { getPreviewVolume } from "@/lib/preview";
|
||||
import { createPreviewPlayback, type PreviewPlayback } from "@/lib/preview-player";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
const formatDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000);
|
||||
@@ -21,6 +22,37 @@ const formatDate = (timestamp: number) => {
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
};
|
||||
const getHistoryFormatLabel = (item: DownloadHistoryItem) => {
|
||||
const normalizedPath = (item.path || "").trim().toLowerCase();
|
||||
if (normalizedPath.endsWith(".flac"))
|
||||
return "FLAC";
|
||||
if (normalizedPath.endsWith(".mp3"))
|
||||
return "MP3";
|
||||
if (normalizedPath.endsWith(".m4a"))
|
||||
return "M4A";
|
||||
const normalizedFormat = (item.format || "").trim().toLowerCase();
|
||||
switch (normalizedFormat) {
|
||||
case "hi_res":
|
||||
case "hi_res_lossless":
|
||||
case "lossless":
|
||||
case "flac":
|
||||
case "6":
|
||||
case "7":
|
||||
case "27":
|
||||
return "FLAC";
|
||||
case "alac":
|
||||
case "apple":
|
||||
case "atmos":
|
||||
case "m4a":
|
||||
case "m4a-aac":
|
||||
case "m4a-alac":
|
||||
return "M4A";
|
||||
case "mp3":
|
||||
return "MP3";
|
||||
default:
|
||||
return (item.format || "-").toUpperCase();
|
||||
}
|
||||
};
|
||||
interface DownloadHistoryItem {
|
||||
id: string;
|
||||
spotify_id: string;
|
||||
@@ -43,6 +75,7 @@ interface FetchHistoryItem {
|
||||
info: string;
|
||||
image: string;
|
||||
data: string;
|
||||
is_explicit?: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
interface HistoryPageProps {
|
||||
@@ -57,7 +90,7 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
||||
const [downloadSortBy, setDownloadSortBy] = useState("default");
|
||||
const [downloadCurrentPage, setDownloadCurrentPage] = useState(1);
|
||||
const [playingPreviewId, setPlayingPreviewId] = useState<string | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const playbackRef = useRef<PreviewPlayback | null>(null);
|
||||
const [fetchHistory, setFetchHistory] = useState<FetchHistoryItem[]>([]);
|
||||
const [filteredFetchHistory, setFilteredFetchHistory] = useState<FetchHistoryItem[]>([]);
|
||||
const [activeFetchTab, setActiveFetchTab] = useState("track");
|
||||
@@ -122,9 +155,8 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
||||
}, [activeTab]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
}
|
||||
playbackRef.current?.destroy();
|
||||
playbackRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
@@ -180,20 +212,35 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
||||
}, [fetchSearchQuery, activeFetchTab]);
|
||||
const handlePreview = async (id: string, spotifyId: string) => {
|
||||
if (playingPreviewId === id) {
|
||||
audioRef.current?.pause();
|
||||
playbackRef.current?.destroy();
|
||||
playbackRef.current = null;
|
||||
setPlayingPreviewId(null);
|
||||
return;
|
||||
}
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
if (playbackRef.current) {
|
||||
playbackRef.current.destroy();
|
||||
playbackRef.current = null;
|
||||
}
|
||||
try {
|
||||
const url = await GetPreviewURL(spotifyId);
|
||||
if (url) {
|
||||
const audio = new Audio(url);
|
||||
audioRef.current = audio;
|
||||
audio.volume = SPOTIFY_PREVIEW_VOLUME;
|
||||
audio.onended = () => setPlayingPreviewId(null);
|
||||
const playback = await createPreviewPlayback(url, getPreviewVolume());
|
||||
const audio = playback.audio;
|
||||
playbackRef.current = playback;
|
||||
audio.onended = () => {
|
||||
setPlayingPreviewId(null);
|
||||
if (playbackRef.current?.audio === audio) {
|
||||
playbackRef.current.destroy();
|
||||
playbackRef.current = null;
|
||||
}
|
||||
};
|
||||
audio.onerror = () => {
|
||||
setPlayingPreviewId(null);
|
||||
if (playbackRef.current?.audio === audio) {
|
||||
playbackRef.current.destroy();
|
||||
playbackRef.current = null;
|
||||
}
|
||||
};
|
||||
audio.play();
|
||||
setPlayingPreviewId(id);
|
||||
}
|
||||
@@ -271,7 +318,7 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
||||
<Input placeholder="Search downloads..." value={downloadSearchQuery} onChange={(e) => setDownloadSearchQuery(e.target.value)} className="pl-8 h-9"/>
|
||||
</div>
|
||||
<Select value={downloadSortBy} onValueChange={setDownloadSortBy}>
|
||||
<SelectTrigger className="w-[180px] h-9">
|
||||
<SelectTrigger className="w-45 h-9">
|
||||
<ArrowUpDown className="mr-2 h-4 w-4"/>
|
||||
<SelectValue placeholder="Sort by"/>
|
||||
</SelectTrigger>
|
||||
@@ -329,10 +376,10 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
||||
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
||||
<div className="truncate">{item.album}</div>
|
||||
</td>
|
||||
<td className="p-3 align-middle text-left hidden lg:table-cell">
|
||||
<td className="p-3 align-middle text-left hidden lg:table-cell">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<span className="text-xs font-bold text-foreground">
|
||||
{['HI_RES_LOSSLESS', 'LOSSLESS', 'flac', '6', '7', '27'].includes(item.format?.toLowerCase() || '') ? 'FLAC' : item.format?.toUpperCase()}
|
||||
{getHistoryFormatLabel(item)}
|
||||
</span>
|
||||
{item.quality && <span className="text-[11px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>}
|
||||
</div>
|
||||
@@ -520,7 +567,10 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
||||
{item.type.slice(0, 2).toUpperCase()}
|
||||
</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>
|
||||
</td>
|
||||
<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 { Button } from "@/components/ui/button";
|
||||
import { useEffect, useState } from "react";
|
||||
import { openExternal } from "@/lib/utils";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
|
||||
import { Star, GitFork, Clock, Download, Blocks, Heart, Copy, CircleCheck, Info } from "lucide-react";
|
||||
import { Star, GitFork, Clock, Download, Info } from "lucide-react";
|
||||
import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
|
||||
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
|
||||
import XIcon from "@/assets/x.webp";
|
||||
import XProIcon from "@/assets/x-pro.webp";
|
||||
import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
|
||||
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
|
||||
import SpotiFLACNextIcon from "@/assets/icons/next.svg";
|
||||
import KofiLogo from "@/assets/ko-fi.gif";
|
||||
import KofiSvg from "@/assets/kofi_symbol.svg";
|
||||
import UsdtBarcode from "@/assets/usdt.jpg";
|
||||
import { langColors } from "@/assets/github-lang-colors";
|
||||
const browserExtensionItems = [
|
||||
{ icon: AudioTTSProIcon, label: "AudioTTS Pro", alt: "AudioTTS Pro" },
|
||||
{ icon: ChatGPTTTSIcon, label: "ChatGPT TTS", alt: "ChatGPT TTS" },
|
||||
{ icon: XIcon, label: "Twitter/X Media Batch Downloader", alt: "Twitter/X Media Batch Downloader" },
|
||||
{ icon: XProIcon, label: "Twitter/X Media Batch Downloader Pro", alt: "Twitter/X Media Batch Downloader Pro" },
|
||||
];
|
||||
const projectCardClass = "cursor-pointer gap-3 py-5 transition-colors hover:bg-muted/50 dark:hover:bg-accent/50";
|
||||
const projectCardHeaderClass = "px-5 gap-1.5";
|
||||
@@ -26,10 +20,8 @@ const projectCardContentClass = "px-5";
|
||||
const projectBodyClass = "text-[13px] leading-snug";
|
||||
const releaseMetaClass = "text-xs text-muted-foreground whitespace-nowrap";
|
||||
const releaseVersionClass = "text-xs bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold whitespace-nowrap";
|
||||
export function AboutPage() {
|
||||
const [activeTab, setActiveTab] = useState<"projects" | "support">("projects");
|
||||
export function OtherProjects() {
|
||||
const [repoStats, setRepoStats] = useState<Record<string, any>>({});
|
||||
const [copiedUsdt, setCopiedUsdt] = useState(false);
|
||||
useEffect(() => {
|
||||
const fetchRepoStats = async () => {
|
||||
const CACHE_KEY = "github_repo_stats_v4";
|
||||
@@ -181,24 +173,10 @@ export function AboutPage() {
|
||||
};
|
||||
return (<div className="flex flex-col space-y-3">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h2 className="text-2xl font-bold tracking-tight">About</h2>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Other Projects</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 border-b shrink-0">
|
||||
<Button variant={activeTab === "projects" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("projects")} className="rounded-b-none">
|
||||
<Blocks className="h-4 w-4"/>
|
||||
Other Projects
|
||||
</Button>
|
||||
<Button variant={activeTab === "support" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("support")} className="rounded-b-none">
|
||||
<Heart className="h-4 w-4"/>
|
||||
Support Me
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
|
||||
|
||||
{activeTab === "projects" && (<div className="pr-1.5">
|
||||
<div className="flex-1 min-h-0 pr-1.5">
|
||||
<div className="grid gap-2 grid-cols-3">
|
||||
<Card className={projectCardClass} onClick={() => openExternal("https://github.com/spotbye/SpotiFLAC-Next")}>
|
||||
<CardHeader className={projectCardHeaderClass}>
|
||||
@@ -223,9 +201,9 @@ export function AboutPage() {
|
||||
{repoStats["SpotiFLAC-Next"] && (<CardContent className={`${projectCardContentClass} space-y-2`}>
|
||||
{repoStats["SpotiFLAC-Next"].languages?.length > 0 && (<div className="flex flex-wrap gap-2 text-xs">
|
||||
{repoStats["SpotiFLAC-Next"].languages.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
|
||||
backgroundColor: getLangColor(lang) + "20",
|
||||
color: getLangColor(lang),
|
||||
}}>
|
||||
backgroundColor: getLangColor(lang) + "20",
|
||||
color: getLangColor(lang),
|
||||
}}>
|
||||
{lang}
|
||||
</span>))}
|
||||
</div>)}
|
||||
@@ -249,7 +227,7 @@ export function AboutPage() {
|
||||
Note
|
||||
</div>
|
||||
<p className="text-xs leading-snug text-sky-700 dark:text-sky-300">
|
||||
This project was created as a thank-you to everyone who has supported SpotiFLAC on Ko-fi.
|
||||
This project released as a token of appreciation for those who have supported SpotiFLAC through Ko-fi, Patreon, or crypto. It’s not a paid product, but it’s shared privately through a supporter-only post.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>)}
|
||||
@@ -277,9 +255,9 @@ export function AboutPage() {
|
||||
{repoStats["Twitter-X-Media-Batch-Downloader"] && (<CardContent className={`${projectCardContentClass} space-y-2`}>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
{repoStats["Twitter-X-Media-Batch-Downloader"].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
|
||||
backgroundColor: getLangColor(lang) + "20",
|
||||
color: getLangColor(lang),
|
||||
}}>
|
||||
backgroundColor: getLangColor(lang) + "20",
|
||||
color: getLangColor(lang),
|
||||
}}>
|
||||
{lang}
|
||||
</span>))}
|
||||
</div>
|
||||
@@ -295,30 +273,30 @@ export function AboutPage() {
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5"/>{" "}
|
||||
{formatTimeAgo(repoStats["Twitter-X-Media-Batch-Downloader"]
|
||||
.createdAt)}
|
||||
.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
|
||||
<span className="flex items-center gap-1">
|
||||
<Download className="h-3.5 w-3.5"/> TOTAL:{" "}
|
||||
{formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"]
|
||||
.totalDownloads)}
|
||||
.totalDownloads)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||
<Download className="h-3.5 w-3.5"/> LATEST:{" "}
|
||||
{formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"]
|
||||
.latestDownloads)}
|
||||
.latestDownloads)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>)}
|
||||
</Card>
|
||||
<div className="flex h-full flex-col gap-1.5">
|
||||
<Card className={`${projectCardClass} flex-1`} onClick={() => openExternal("https://exyezed.qzz.io/")}>
|
||||
<Card className={`${projectCardClass} flex-1`} onClick={() => openExternal("https://exyezed.fyi/")}>
|
||||
<CardHeader className={projectCardHeaderClass}>
|
||||
<CardTitle className="leading-tight">Browser Extensions & Scripts</CardTitle>
|
||||
<CardDescription className="flex flex-col gap-2.5 pt-1.5">
|
||||
{browserExtensionItems.map((item) => (<div key={item.alt} className="flex items-center gap-2.5">
|
||||
<img src={item.icon} className="h-[22px] w-[22px] rounded-sm shadow-sm" alt={item.alt}/>
|
||||
<img src={item.icon} className="h-5.5 w-5.5 rounded-sm shadow-sm" alt={item.alt}/>
|
||||
<span className={`${projectBodyClass} text-muted-foreground`}>
|
||||
{item.label}
|
||||
</span>
|
||||
@@ -339,55 +317,6 @@ export function AboutPage() {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{activeTab === "support" && (<div className="flex flex-col items-center justify-center p-4 space-y-6">
|
||||
<div className="flex flex-col md:flex-row w-full max-w-3xl bg-card rounded-xl border shadow-sm">
|
||||
|
||||
<div className="flex-1 p-6 flex flex-col items-center justify-between border-b md:border-b-0 md:border-r space-y-6">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="h-32 flex items-center justify-center w-full relative">
|
||||
<img src={KofiLogo} className="w-72 absolute pointer-events-none" alt="Ko-fi"/>
|
||||
</div>
|
||||
<h4 className="font-semibold text-foreground">Support via Ko-fi</h4>
|
||||
<p className="text-sm text-muted-foreground text-center px-4">
|
||||
Enjoying the project? You can support ongoing development by buying me a coffee.
|
||||
</p>
|
||||
</div>
|
||||
<Button className="h-10 w-full text-sm font-semibold text-white gap-2 group bg-[#72a4f2] hover:bg-[#5f8cd6]" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
||||
<img src={KofiSvg} className="w-5 h-5 shrink-0" alt="" aria-hidden="true"/>
|
||||
Support me on Ko-fi
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex-1 p-6 flex flex-col items-center justify-between space-y-6">
|
||||
<div className="flex flex-col items-center space-y-4 w-full">
|
||||
<div className="h-32 flex items-center justify-center">
|
||||
<div className="p-2 bg-white rounded-xl shadow-sm border">
|
||||
<img src={UsdtBarcode} className="w-24 h-24 object-contain" alt="USDT Barcode"/>
|
||||
</div>
|
||||
</div>
|
||||
<h4 className="font-semibold text-foreground">USDT (TRC20)</h4>
|
||||
<p className="text-sm text-muted-foreground text-center px-4">
|
||||
Crypto donations are also accepted. Scan the QR code or copy the address.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-muted/50 pl-3 pr-1.5 py-1.5 rounded-lg border w-full justify-between h-10">
|
||||
<code className="text-xs font-mono text-muted-foreground truncate" title="THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs">
|
||||
THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs
|
||||
</code>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 hover:bg-background" onClick={() => {
|
||||
navigator.clipboard.writeText("THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs");
|
||||
setCopiedUsdt(true);
|
||||
setTimeout(() => setCopiedUsdt(false), 500);
|
||||
}}>
|
||||
{copiedUsdt ? <CircleCheck className="h-3.5 w-3.5 text-green-500"/> : <Copy className="h-3.5 w-3.5"/>}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
@@ -41,6 +41,7 @@ interface PlaylistInfoProps {
|
||||
isDownloading: boolean;
|
||||
bulkDownloadType: "all" | "selected" | null;
|
||||
downloadProgress: number;
|
||||
downloadRemainingCount: number;
|
||||
currentDownloadInfo: {
|
||||
name: string;
|
||||
artists: string;
|
||||
@@ -88,7 +89,7 @@ interface PlaylistInfoProps {
|
||||
onTrackClick: (track: TrackMetadata) => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, onBack, }: PlaylistInfoProps) {
|
||||
export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, downloadRemainingCount, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, onBack, }: PlaylistInfoProps) {
|
||||
const settings = getSettings();
|
||||
const playlistName = playlistInfo.owner.name;
|
||||
const playlistFolderName = buildPlaylistFolderName(playlistName, playlistInfo.owner.display_name, settings.playlistOwnerFolderName);
|
||||
@@ -235,7 +236,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
</div>
|
||||
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||
{isDownloading && (<DownloadProgress progress={downloadProgress} remainingCount={downloadRemainingCount} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -604,14 +604,22 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
||||
|
||||
{!searchMode && (<>
|
||||
{showRegionSelector && (<Select value={region} onValueChange={onRegionChange}>
|
||||
<SelectTrigger className="w-[70px] shrink-0">
|
||||
<SelectValue placeholder="Region"/>
|
||||
<SelectTrigger className="w-22.5 shrink-0">
|
||||
<SelectValue placeholder="Region">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<img src={`/assets/flags/${region.toLowerCase()}.svg`} alt={region} className="h-3 w-4 rounded-[1px] object-cover shrink-0"/>
|
||||
{region}
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[300px]">
|
||||
{REGIONS.map((r) => (<SelectItem key={r} value={r} textValue={r}>
|
||||
{r}{" "}
|
||||
<span className="text-muted-foreground">
|
||||
({getRegionName(r)})
|
||||
<span className="flex items-center gap-1.5">
|
||||
<img src={`/assets/flags/${r.toLowerCase()}.svg`} alt="" className="h-3 w-4 rounded-[1px] object-cover shrink-0"/>
|
||||
{r}{" "}
|
||||
<span className="text-muted-foreground">
|
||||
({getRegionName(r)})
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>))}
|
||||
</SelectContent>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,18 +6,19 @@ import { ActivityIcon, type ActivityIconHandle } from "@/components/ui/activity"
|
||||
import { TerminalIcon } from "@/components/ui/terminal";
|
||||
import { FileMusicIcon, type FileMusicIconHandle } from "@/components/ui/file-music";
|
||||
import { FilePenIcon, type FilePenIconHandle } from "@/components/ui/file-pen";
|
||||
import { FileTextIcon, type FileTextIconHandle } from "@/components/ui/file-text";
|
||||
import { BugReportIcon } from "@/components/ui/bug-report-icon";
|
||||
import { CoffeeIcon } from "@/components/ui/coffee";
|
||||
import { BadgeAlertIcon } from "@/components/ui/badge-alert";
|
||||
import { GithubIcon } from "@/components/ui/github";
|
||||
import { BlocksIcon } from "@/components/ui/blocks-icon";
|
||||
import { AudioLinesIcon, type AudioLinesIconHandle } from "@/components/ui/audio-lines";
|
||||
import { ToolCaseIcon } from "@/components/ui/tool-case";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { openExternal } from "@/lib/utils";
|
||||
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "about" | "history";
|
||||
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "lyrics-manager" | "projects" | "support" | "history";
|
||||
interface SidebarProps {
|
||||
currentPage: PageType;
|
||||
onPageChange: (page: PageType) => void;
|
||||
@@ -33,6 +34,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
const resamplerIconRef = useRef<AudioLinesIconHandle>(null);
|
||||
const converterIconRef = useRef<FileMusicIconHandle>(null);
|
||||
const fileManagerIconRef = useRef<FilePenIconHandle>(null);
|
||||
const lyricsManagerIconRef = useRef<FileTextIconHandle>(null);
|
||||
const handleIssuesDialogChange = (open: boolean) => {
|
||||
setIsIssuesDialogOpen(open);
|
||||
if (!open) {
|
||||
@@ -99,8 +101,8 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
<Tooltip delayDuration={0}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant={["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`}>
|
||||
<BlocksIcon size={20} loop={true}/>
|
||||
<Button variant={["audio-analysis", "audio-converter", "audio-resampler", "file-manager", "lyrics-manager"].includes(currentPage) ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${["audio-analysis", "audio-converter", "audio-resampler", "file-manager", "lyrics-manager"].includes(currentPage) ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`}>
|
||||
<ToolCaseIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -125,6 +127,10 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
<FilePenIcon ref={fileManagerIconRef} size={16}/>
|
||||
<span>File Manager</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onPageChange("lyrics-manager")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(lyricsManagerIconRef)}>
|
||||
<FileTextIcon ref={lyricsManagerIconRef} size={16}/>
|
||||
<span>Lyrics Manager</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -134,7 +140,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => setIsIssuesDialogOpen(true)}>
|
||||
<GithubIcon size={20}/>
|
||||
<BugReportIcon size={20} loop={true}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
@@ -176,23 +182,23 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant={currentPage === "about" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "about" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("about")}>
|
||||
<BadgeAlertIcon size={20}/>
|
||||
<Button variant={currentPage === "projects" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "projects" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("projects")}>
|
||||
<BlocksIcon size={20} loop={true}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>About</p>
|
||||
<p>Other Projects</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
||||
<Button variant={currentPage === "support" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "support" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("support")}>
|
||||
<CoffeeIcon size={20} loop={true}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Support me on Ko-fi</p>
|
||||
<p>Support Me</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { X, Minus, Maximize, SlidersHorizontal, Globe, Eye, EyeOff } from "lucide-react";
|
||||
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
||||
import { Menubar, MenubarContent, MenubarMenu, MenubarItem, MenubarTrigger, MenubarLabel, MenubarSeparator } from "@/components/ui/menubar";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { getSettings, updateSettings } from "@/lib/settings";
|
||||
import { PREVIEW_VOLUME_CHANGED_EVENT } from "@/lib/preview";
|
||||
import { fetchCurrentIPInfo } from "@/lib/api";
|
||||
import type { CurrentIPInfo } from "@/types/api";
|
||||
import { openExternal } from "@/lib/utils";
|
||||
@@ -24,7 +27,12 @@ const SPOTIFY_BLOCKED_COUNTRY_CODES = new Set([
|
||||
"TM",
|
||||
"YE",
|
||||
]);
|
||||
interface SettingsUpdatedDetail {
|
||||
previewVolume?: number;
|
||||
}
|
||||
export function TitleBar() {
|
||||
const initialSettings = getSettings();
|
||||
const [previewVolume, setPreviewVolume] = useState(initialSettings.previewVolume ?? 100);
|
||||
const [currentIPInfo, setCurrentIPInfo] = useState<CurrentIPInfo | null>(null);
|
||||
const [isLoadingCurrentIPInfo, setIsLoadingCurrentIPInfo] = useState(false);
|
||||
const [currentIPInfoError, setCurrentIPInfoError] = useState("");
|
||||
@@ -33,6 +41,16 @@ export function TitleBar() {
|
||||
useEffect(() => {
|
||||
currentIPInfoRef.current = currentIPInfo;
|
||||
}, [currentIPInfo]);
|
||||
useEffect(() => {
|
||||
const handleSettingsUpdate = (event: Event) => {
|
||||
const updatedSettings = (event as CustomEvent<SettingsUpdatedDetail>).detail;
|
||||
if (updatedSettings && typeof updatedSettings.previewVolume === "number") {
|
||||
setPreviewVolume(updatedSettings.previewVolume);
|
||||
}
|
||||
};
|
||||
window.addEventListener("settingsUpdated", handleSettingsUpdate);
|
||||
return () => window.removeEventListener("settingsUpdated", handleSettingsUpdate);
|
||||
}, []);
|
||||
const loadCurrentIPInfo = async (options?: {
|
||||
silent?: boolean;
|
||||
}) => {
|
||||
@@ -88,6 +106,22 @@ export function TitleBar() {
|
||||
const handleClose = () => {
|
||||
Quit();
|
||||
};
|
||||
const handlePreviewVolumeChange = (value: number[]) => {
|
||||
const nextValue = value[0];
|
||||
if (typeof nextValue !== "number" || Number.isNaN(nextValue)) {
|
||||
return;
|
||||
}
|
||||
setPreviewVolume(nextValue);
|
||||
window.dispatchEvent(new CustomEvent(PREVIEW_VOLUME_CHANGED_EVENT, { detail: nextValue }));
|
||||
};
|
||||
const handlePreviewVolumeCommit = (value: number[]) => {
|
||||
const nextValue = value[0];
|
||||
if (typeof nextValue !== "number" || Number.isNaN(nextValue)) {
|
||||
return;
|
||||
}
|
||||
setPreviewVolume(nextValue);
|
||||
void updateSettings({ previewVolume: nextValue });
|
||||
};
|
||||
const detectedCountryCode = currentIPInfo?.country_code?.toUpperCase() || "";
|
||||
const detectedFlagPath = detectedCountryCode ? `/assets/flags/${detectedCountryCode.toLowerCase()}.svg` : "";
|
||||
const isSpotifyBlockedCountry = detectedCountryCode !== "" && SPOTIFY_BLOCKED_COUNTRY_CODES.has(detectedCountryCode);
|
||||
@@ -102,7 +136,17 @@ export function TitleBar() {
|
||||
<MenubarTrigger className="cursor-pointer w-8 h-7 p-0 flex items-center justify-center hover:bg-muted transition-colors rounded data-[state=open]:bg-muted">
|
||||
<SlidersHorizontal className="w-3.5 h-3.5"/>
|
||||
</MenubarTrigger>
|
||||
<MenubarContent align="end" className="min-w-[280px]">
|
||||
<MenubarContent align="end" className="min-w-70">
|
||||
<div className="px-2 py-1.5 space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<MenubarLabel className="p-0">Preview Volume</MenubarLabel>
|
||||
<span className="text-xs font-medium text-muted-foreground tabular-nums">
|
||||
{previewVolume}%
|
||||
</span>
|
||||
</div>
|
||||
<Slider value={[previewVolume]} min={0} max={100} step={5} onValueChange={handlePreviewVolumeChange} onValueCommit={handlePreviewVolumeCommit} aria-label="Preview volume"/>
|
||||
</div>
|
||||
<MenubarSeparator />
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
||||
<MenubarLabel className="p-0">Network</MenubarLabel>
|
||||
{isSpotifyBlockedCountry && (<span className="text-xs font-medium text-destructive">
|
||||
@@ -112,7 +156,7 @@ export function TitleBar() {
|
||||
<div className="px-2 py-1.5 space-y-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{detectedFlagPath ? (<img src={detectedFlagPath} alt={detectedCountryCode} className="h-3.5 w-[18px] rounded-[2px] border object-cover bg-muted"/>) : (<Globe className="w-4 h-4 opacity-70"/>)}
|
||||
{detectedFlagPath ? (<img src={detectedFlagPath} alt={detectedCountryCode} className="h-3.5 w-4.5 rounded-[2px] border object-cover bg-muted"/>) : (<Globe className="w-4 h-4 opacity-70"/>)}
|
||||
<span className="font-mono text-xs truncate">
|
||||
{isLoadingCurrentIPInfo
|
||||
? "Detecting..."
|
||||
@@ -132,7 +176,7 @@ export function TitleBar() {
|
||||
</div>)}
|
||||
</div>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem onClick={() => openExternal("https://afkarxyz.qzz.io")} className="gap-2">
|
||||
<MenubarItem onClick={() => openExternal("https://afkarxyz.fyi")} className="gap-2">
|
||||
<Globe className="w-4 h-4 opacity-70"/>
|
||||
<span>Website</span>
|
||||
</MenubarItem>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/toolti
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
import { usePreview } from "@/hooks/usePreview";
|
||||
import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks";
|
||||
import { buildClickableArtists } from "@/lib/artist-links";
|
||||
import { buildClickableArtists, getClickableArtistKey } from "@/lib/artist-links";
|
||||
interface TrackInfoProps {
|
||||
track: TrackMetadata & {
|
||||
album_name: string;
|
||||
@@ -83,14 +83,14 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
|
||||
{isSkipped ? (<FileCheck className="h-6 w-6 text-yellow-500 shrink-0"/>) : isDownloaded ? (<CheckCircle className="h-6 w-6 text-green-500 shrink-0"/>) : isFailed ? (<XCircle className="h-6 w-6 text-red-500 shrink-0"/>) : null}
|
||||
</div>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
{clickableArtists.length > 0 ? clickableArtists.map((artist, index) => (<span key={`${artist.id || artist.name}-${index}`}>
|
||||
{onArtistClick ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
|
||||
{clickableArtists.length > 0 ? clickableArtists.map((artist, index) => (<span key={getClickableArtistKey(artist)}>
|
||||
{onArtistClick ? (<button type="button" className="cursor-pointer rounded-sm bg-transparent p-0 text-inherit hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60" onClick={() => onArtistClick({
|
||||
id: artist.id,
|
||||
name: artist.name,
|
||||
external_urls: artist.external_urls,
|
||||
})}>
|
||||
{artist.name}
|
||||
</span>) : (artist.name)}
|
||||
</button>) : (artist.name)}
|
||||
{index < clickableArtists.length - 1 && ", "}
|
||||
</span>)) : track.artists}
|
||||
</p>
|
||||
@@ -99,13 +99,13 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Album</p>
|
||||
<p className="font-medium truncate">{hasAlbumClick ? (<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!,
|
||||
name: track.album_name,
|
||||
external_urls: track.album_url!,
|
||||
})}>
|
||||
{track.album_name}
|
||||
</span>) : (track.album_name)}</p>
|
||||
</button>) : (track.album_name)}</p>
|
||||
</div>
|
||||
{track.plays && (<div>
|
||||
<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 { usePreview } from "@/hooks/usePreview";
|
||||
import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks";
|
||||
import { buildClickableArtists } from "@/lib/artist-links";
|
||||
import { buildClickableArtists, getClickableArtistKey } from "@/lib/artist-links";
|
||||
interface TrackListProps {
|
||||
tracks: TrackMetadata[];
|
||||
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) {
|
||||
const { playPreview, loadingPreview, playingTrack } = usePreview();
|
||||
const getTrackKey = (track: TrackMetadata) => track.spotify_id || track.external_urls || `${track.name}-${track.album_name}-${track.disc_number ?? 1}-${track.track_number}`;
|
||||
let filteredTracks = tracks.filter((track) => {
|
||||
if (!searchQuery)
|
||||
return true;
|
||||
@@ -219,7 +220,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
||||
</tr>
|
||||
</thead>
|
||||
<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">
|
||||
{track.spotify_id && (<Checkbox checked={selectedTracks.includes(track.spotify_id)} onCheckedChange={() => onToggleTrack(track.spotify_id!)}/>)}
|
||||
</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"/>)}
|
||||
<div className="flex flex-col">
|
||||
<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}
|
||||
</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.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) {
|
||||
return track.artists;
|
||||
}
|
||||
return clickableArtists.map((artist, i) => (<span key={`${artist.id || artist.name}-${i}`}>
|
||||
{onArtistClick ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
|
||||
return clickableArtists.map((artist, i) => (<span key={getClickableArtistKey(artist)}>
|
||||
{onArtistClick ? (<button type="button" className="cursor-pointer rounded-sm bg-transparent p-0 text-inherit hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60" onClick={() => onArtistClick({
|
||||
id: artist.id,
|
||||
name: artist.name,
|
||||
external_urls: artist.external_urls,
|
||||
})}>
|
||||
{artist.name}
|
||||
</span>) : (artist.name)}
|
||||
</button>) : (artist.name)}
|
||||
{i < clickableArtists.length - 1 && ", "}
|
||||
</span>));
|
||||
})()}
|
||||
@@ -271,13 +272,13 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
||||
</div>
|
||||
</td>
|
||||
{!hideAlbumColumn && (<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
||||
{onAlbumClick && track.album_id && track.album_url ? (<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!,
|
||||
name: track.album_name,
|
||||
external_urls: track.album_url!,
|
||||
})}>
|
||||
{track.album_name}
|
||||
</span>) : (track.album_name)}
|
||||
</button>) : (track.album_name)}
|
||||
</td>)}
|
||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
|
||||
{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 };
|
||||
@@ -37,14 +37,24 @@ function SelectContent({ className, children, position = "popper", align = "cent
|
||||
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (<SelectPrimitive.Label data-slot="select-label" className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} {...props}/>);
|
||||
}
|
||||
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (<SelectPrimitive.Item data-slot="select-item" className={cn("focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", className)} {...props}>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4"/>
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
function SelectItem({ className, children, indicatorPosition = "right", trailingAction, ...props }: React.ComponentProps<typeof SelectPrimitive.Item> & {
|
||||
indicatorPosition?: "right" | "inline";
|
||||
trailingAction?: React.ReactNode;
|
||||
}) {
|
||||
return (<SelectPrimitive.Item data-slot="select-item" className={cn("focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", indicatorPosition === "right" ? "pr-8" : "pr-2", trailingAction ? "pr-10" : undefined, className)} {...props}>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{indicatorPosition === "inline" && (<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4"/>
|
||||
</SelectPrimitive.ItemIndicator>)}
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{trailingAction ? (<span className="absolute right-2 flex items-center justify-center">
|
||||
{trailingAction}
|
||||
</span>) : indicatorPosition === "right" ? (<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4"/>
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>) : null}
|
||||
</SelectPrimitive.Item>);
|
||||
}
|
||||
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import { cn } from "@/lib/utils";
|
||||
function Slider({ className, defaultValue, value, min = 0, max = 100, ...props }: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const values = Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(defaultValue)
|
||||
? defaultValue
|
||||
: [min];
|
||||
return (<SliderPrimitive.Root data-slot="slider" defaultValue={defaultValue} value={value} min={min} max={max} className={cn("relative flex w-full touch-none select-none items-center data-[disabled]:opacity-50", className)} {...props}>
|
||||
<SliderPrimitive.Track data-slot="slider-track" className="relative h-2 w-full grow overflow-hidden rounded-full bg-muted">
|
||||
<SliderPrimitive.Range data-slot="slider-range" className="absolute h-full rounded-full bg-primary"/>
|
||||
</SliderPrimitive.Track>
|
||||
{values.map((_, index) => (<SliderPrimitive.Thumb key={index} data-slot="slider-thumb" className="block size-4 shrink-0 rounded-full border-2 border-primary bg-background shadow-sm transition-[color,box-shadow] hover:shadow-md focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50"/>))}
|
||||
</SliderPrimitive.Root>);
|
||||
}
|
||||
export { Slider };
|
||||
@@ -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 { API_SOURCES, checkApiStatus, getApiStatusState, subscribeApiStatus, } from "@/lib/api-status";
|
||||
import { API_SOURCES, checkApiStatus, checkCurrentApiStatusesOnly, checkSpotiFLACNextStatusesOnly, getApiStatusState, subscribeApiStatus, } from "@/lib/api-status";
|
||||
export function useApiStatus() {
|
||||
const [state, setState] = useState(getApiStatusState);
|
||||
useEffect(() => {
|
||||
@@ -11,5 +11,7 @@ export function useApiStatus() {
|
||||
...state,
|
||||
sources: API_SOURCES,
|
||||
checkOne: (sourceId: string) => checkApiStatus(sourceId),
|
||||
checkAllCurrent: () => checkCurrentApiStatusesOnly(),
|
||||
checkAllNext: () => checkSpotiFLACNextStatusesOnly(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { downloadTrack, fetchSpotifyMetadata } from "@/lib/api";
|
||||
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
|
||||
import { getSettings, parseTemplate, sanitizeAutoOrder, type TemplateData } from "@/lib/settings";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils";
|
||||
import { logger } from "@/lib/logger";
|
||||
@@ -36,13 +36,17 @@ const GetTrackISRC = (spotifyId: string): Promise<string> => (window as any)["go
|
||||
async function resolveTemplateISRC(settings: {
|
||||
folderTemplate?: string;
|
||||
filenameTemplate?: string;
|
||||
existingFileCheckMode?: string;
|
||||
}, spotifyId?: string): Promise<string> {
|
||||
if (!spotifyId) {
|
||||
return "";
|
||||
}
|
||||
const folderTemplate = settings.folderTemplate || "";
|
||||
const filenameTemplate = settings.filenameTemplate || "";
|
||||
if (!folderTemplate.includes("{isrc}") && !filenameTemplate.includes("{isrc}")) {
|
||||
const shouldResolveISRC = settings.existingFileCheckMode === "isrc" ||
|
||||
folderTemplate.includes("{isrc}") ||
|
||||
filenameTemplate.includes("{isrc}");
|
||||
if (!shouldResolveISRC) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
@@ -52,26 +56,18 @@ async function resolveTemplateISRC(settings: {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
function getTidalVariant(settings: any): "tidal" | "alt" {
|
||||
return settings?.tidalVariant === "alt" ? "alt" : "tidal";
|
||||
}
|
||||
function isTidalAltVariant(settings: any): boolean {
|
||||
return getTidalVariant(settings) === "alt";
|
||||
}
|
||||
function getTidalAudioFormat(settings: any, mode: "single" | "auto"): "LOSSLESS" | "HI_RES_LOSSLESS" {
|
||||
if (isTidalAltVariant(settings)) {
|
||||
return "LOSSLESS";
|
||||
}
|
||||
if (mode === "auto") {
|
||||
return (settings.autoQuality || "24") === "24" ? "HI_RES_LOSSLESS" : "LOSSLESS";
|
||||
}
|
||||
return settings.tidalQuality || "LOSSLESS";
|
||||
}
|
||||
function shouldFetchStreamingURLs(order: string[], settings: any): boolean {
|
||||
return order.includes("amazon") || (order.includes("tidal") && !isTidalAltVariant(settings));
|
||||
function shouldFetchStreamingURLs(order: string[]): boolean {
|
||||
return order.includes("amazon") || order.includes("tidal");
|
||||
}
|
||||
export function useDownload(region: string) {
|
||||
const [downloadProgress, setDownloadProgress] = useState<number>(0);
|
||||
const [downloadRemainingCount, setDownloadRemainingCount] = useState<number>(0);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null);
|
||||
const [bulkDownloadType, setBulkDownloadType] = useState<"all" | "selected" | null>(null);
|
||||
@@ -83,10 +79,22 @@ export function useDownload(region: string) {
|
||||
artists: string;
|
||||
} | null>(null);
|
||||
const shouldStopDownloadRef = useRef(false);
|
||||
const updateBatchProgress = (completedCount: number, totalCount: number) => {
|
||||
const safeTotalCount = Math.max(0, totalCount);
|
||||
const safeCompletedCount = Math.min(Math.max(0, completedCount), safeTotalCount);
|
||||
setDownloadProgress(safeTotalCount > 0 ? Math.min(100, Math.round((safeCompletedCount / safeTotalCount) * 100)) : 0);
|
||||
setDownloadRemainingCount(Math.max(0, safeTotalCount - safeCompletedCount));
|
||||
};
|
||||
const downloadWithAutoFallback = async (id: string, settings: any, trackName?: string, artistName?: string, albumName?: string, playlistName?: string, position?: number, spotifyId?: string, durationMs?: number, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
|
||||
const service = settings.downloader;
|
||||
const query = trackName && artistName ? `${trackName} ${artistName} ` : undefined;
|
||||
const os = settings.operatingSystem;
|
||||
const customTidalApi = typeof settings.customTidalApi === "string" && settings.customTidalApi.trim().startsWith("https://")
|
||||
? settings.customTidalApi.trim().replace(/\/+$/g, "")
|
||||
: undefined;
|
||||
const customQobuzApi = typeof settings.customQobuzApi === "string" && settings.customQobuzApi.trim().startsWith("https://")
|
||||
? settings.customQobuzApi.trim().replace(/\/+$/g, "")
|
||||
: undefined;
|
||||
let outputDir = settings.downloadPath;
|
||||
let useAlbumTrackNumber = false;
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
@@ -188,11 +196,9 @@ export function useDownload(region: string) {
|
||||
itemID = await AddToDownloadQueue(id, trackName || "", displayArtist || "", albumName || "");
|
||||
}
|
||||
if (service === "auto") {
|
||||
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
||||
const tidalVariant = getTidalVariant(settings);
|
||||
const tidalLabel = tidalVariant === "alt" ? "Tidal Alt." : "Tidal";
|
||||
const order = sanitizeAutoOrder(settings.autoOrder).split("-");
|
||||
let streamingURLs: any = null;
|
||||
if (spotifyId && shouldFetchStreamingURLs(order, settings)) {
|
||||
if (spotifyId && shouldFetchStreamingURLs(order)) {
|
||||
try {
|
||||
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
|
||||
const urlsJson = await GetStreamingURLs(spotifyId, region);
|
||||
@@ -209,9 +215,9 @@ export function useDownload(region: string) {
|
||||
const is24Bit = (settings.autoQuality || "24") === "24";
|
||||
const qobuzQuality = is24Bit ? "27" : "6";
|
||||
for (const s of order) {
|
||||
if (s === "tidal" && ((tidalVariant === "alt" && spotifyId) || streamingURLs?.tidal_url)) {
|
||||
if (s === "tidal" && streamingURLs?.tidal_url) {
|
||||
try {
|
||||
logger.debug(`trying ${tidalLabel} for: ${trackName} - ${artistName}`);
|
||||
logger.debug(`trying Tidal for: ${trackName} - ${artistName}`);
|
||||
const response = await downloadTrack({
|
||||
service: "tidal",
|
||||
query,
|
||||
@@ -229,11 +235,11 @@ export function useDownload(region: string) {
|
||||
spotify_id: spotifyId,
|
||||
embed_lyrics: settings.embedLyrics,
|
||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||
service_url: tidalVariant === "alt" ? undefined : streamingURLs?.tidal_url,
|
||||
tidal_variant: tidalVariant,
|
||||
service_url: streamingURLs?.tidal_url,
|
||||
duration: durationSeconds,
|
||||
item_id: itemID,
|
||||
audio_format: tidalQuality,
|
||||
tidal_api_url: customTidalApi,
|
||||
spotify_track_number: spotifyTrackNumber,
|
||||
spotify_disc_number: spotifyDiscNumber,
|
||||
spotify_total_tracks: spotifyTotalTracks,
|
||||
@@ -246,17 +252,17 @@ export function useDownload(region: string) {
|
||||
embed_genre: settings.embedGenre,
|
||||
});
|
||||
if (response.success) {
|
||||
logger.success(`${tidalLabel}: ${trackName} - ${artistName}`);
|
||||
logger.success(`Tidal: ${trackName} - ${artistName}`);
|
||||
return response;
|
||||
}
|
||||
const errMsg = response.error || response.message || "Failed";
|
||||
fallbackErrors.push(`[${tidalLabel}] ${errMsg}`);
|
||||
fallbackErrors.push(`[Tidal] ${errMsg}`);
|
||||
lastResponse = response;
|
||||
logger.warning(`${tidalLabel} failed, trying next...`);
|
||||
logger.warning(`Tidal failed, trying next...`);
|
||||
}
|
||||
catch (err) {
|
||||
logger.error(`${tidalLabel} error: ${err}`);
|
||||
fallbackErrors.push(`[${tidalLabel}] ${String(err)}`);
|
||||
logger.error(`Tidal error: ${err}`);
|
||||
fallbackErrors.push(`[Tidal] ${String(err)}`);
|
||||
lastResponse = { success: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
@@ -282,6 +288,7 @@ export function useDownload(region: string) {
|
||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||
service_url: streamingURLs.amazon_url,
|
||||
item_id: itemID,
|
||||
audio_format: is24Bit ? "24" : "16",
|
||||
spotify_track_number: spotifyTrackNumber,
|
||||
spotify_disc_number: spotifyDiscNumber,
|
||||
spotify_total_tracks: spotifyTotalTracks,
|
||||
@@ -329,6 +336,7 @@ export function useDownload(region: string) {
|
||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||
item_id: itemID,
|
||||
audio_format: qobuzQuality,
|
||||
qobuz_api_url: customQobuzApi,
|
||||
spotify_track_number: spotifyTrackNumber,
|
||||
spotify_disc_number: spotifyDiscNumber,
|
||||
spotify_total_tracks: spotifyTotalTracks,
|
||||
@@ -370,6 +378,9 @@ export function useDownload(region: string) {
|
||||
else if (service === "qobuz") {
|
||||
audioFormat = settings.qobuzQuality || "6";
|
||||
}
|
||||
else if (service === "amazon") {
|
||||
audioFormat = settings.amazonQuality || "16";
|
||||
}
|
||||
else if (service === "deezer") {
|
||||
audioFormat = "flac";
|
||||
}
|
||||
@@ -394,7 +405,8 @@ export function useDownload(region: string) {
|
||||
duration: durationSecondsForFallback,
|
||||
item_id: itemID,
|
||||
audio_format: audioFormat,
|
||||
tidal_variant: service === "tidal" ? getTidalVariant(settings) : undefined,
|
||||
tidal_api_url: service === "tidal" ? customTidalApi : undefined,
|
||||
qobuz_api_url: service === "qobuz" ? customQobuzApi : undefined,
|
||||
spotify_track_number: spotifyTrackNumber,
|
||||
spotify_disc_number: spotifyDiscNumber,
|
||||
spotify_total_tracks: spotifyTotalTracks,
|
||||
@@ -416,6 +428,12 @@ export function useDownload(region: string) {
|
||||
const service = settings.downloader;
|
||||
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
|
||||
const os = settings.operatingSystem;
|
||||
const customTidalApi = typeof settings.customTidalApi === "string" && settings.customTidalApi.trim().startsWith("https://")
|
||||
? settings.customTidalApi.trim().replace(/\/+$/g, "")
|
||||
: undefined;
|
||||
const customQobuzApi = typeof settings.customQobuzApi === "string" && settings.customQobuzApi.trim().startsWith("https://")
|
||||
? settings.customQobuzApi.trim().replace(/\/+$/g, "")
|
||||
: undefined;
|
||||
let outputDir = settings.downloadPath;
|
||||
let useAlbumTrackNumber = false;
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
@@ -474,11 +492,9 @@ export function useDownload(region: string) {
|
||||
}
|
||||
}
|
||||
if (service === "auto") {
|
||||
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
||||
const tidalVariant = getTidalVariant(settings);
|
||||
const tidalLabel = tidalVariant === "alt" ? "Tidal Alt." : "Tidal";
|
||||
const order = sanitizeAutoOrder(settings.autoOrder).split("-");
|
||||
let streamingURLs: any = null;
|
||||
if (spotifyId && shouldFetchStreamingURLs(order, settings)) {
|
||||
if (spotifyId && shouldFetchStreamingURLs(order)) {
|
||||
try {
|
||||
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
|
||||
const urlsJson = await GetStreamingURLs(spotifyId, region);
|
||||
@@ -495,9 +511,9 @@ export function useDownload(region: string) {
|
||||
const is24Bit = (settings.autoQuality || "24") === "24";
|
||||
const qobuzQuality = is24Bit ? "27" : "6";
|
||||
for (const s of order) {
|
||||
if (s === "tidal" && ((tidalVariant === "alt" && spotifyId) || streamingURLs?.tidal_url)) {
|
||||
if (s === "tidal" && streamingURLs?.tidal_url) {
|
||||
try {
|
||||
logger.debug(`trying ${tidalLabel} for: ${trackName} - ${artistName}`);
|
||||
logger.debug(`trying Tidal for: ${trackName} - ${artistName}`);
|
||||
const response = await downloadTrack({
|
||||
service: "tidal",
|
||||
query,
|
||||
@@ -515,11 +531,11 @@ export function useDownload(region: string) {
|
||||
spotify_id: spotifyId,
|
||||
embed_lyrics: settings.embedLyrics,
|
||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||
service_url: tidalVariant === "alt" ? undefined : streamingURLs?.tidal_url,
|
||||
tidal_variant: tidalVariant,
|
||||
service_url: streamingURLs?.tidal_url,
|
||||
duration: durationSeconds,
|
||||
item_id: itemID,
|
||||
audio_format: tidalQuality,
|
||||
tidal_api_url: customTidalApi,
|
||||
spotify_track_number: spotifyTrackNumber,
|
||||
spotify_disc_number: spotifyDiscNumber,
|
||||
spotify_total_tracks: spotifyTotalTracks,
|
||||
@@ -532,17 +548,17 @@ export function useDownload(region: string) {
|
||||
embed_genre: settings.embedGenre,
|
||||
});
|
||||
if (response.success) {
|
||||
logger.success(`${tidalLabel}: ${trackName} - ${artistName}`);
|
||||
logger.success(`Tidal: ${trackName} - ${artistName}`);
|
||||
return response;
|
||||
}
|
||||
const errMsg = response.error || response.message || "Failed";
|
||||
fallbackErrors.push(`[${tidalLabel}] ${errMsg}`);
|
||||
fallbackErrors.push(`[Tidal] ${errMsg}`);
|
||||
lastResponse = response;
|
||||
logger.warning(`${tidalLabel} failed, trying next...`);
|
||||
logger.warning(`Tidal failed, trying next...`);
|
||||
}
|
||||
catch (err) {
|
||||
logger.error(`${tidalLabel} error: ${err}`);
|
||||
fallbackErrors.push(`[${tidalLabel}] ${String(err)}`);
|
||||
logger.error(`Tidal error: ${err}`);
|
||||
fallbackErrors.push(`[Tidal] ${String(err)}`);
|
||||
lastResponse = { success: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
@@ -617,6 +633,7 @@ export function useDownload(region: string) {
|
||||
duration: durationSeconds,
|
||||
item_id: itemID,
|
||||
audio_format: qobuzQuality,
|
||||
qobuz_api_url: customQobuzApi,
|
||||
spotify_track_number: spotifyTrackNumber,
|
||||
spotify_disc_number: spotifyDiscNumber,
|
||||
spotify_total_tracks: spotifyTotalTracks,
|
||||
@@ -659,6 +676,9 @@ export function useDownload(region: string) {
|
||||
else if (service === "qobuz") {
|
||||
audioFormat = settings.qobuzQuality || "6";
|
||||
}
|
||||
else if (service === "amazon") {
|
||||
audioFormat = settings.amazonQuality || "16";
|
||||
}
|
||||
const singleServiceResponse = await downloadTrack({
|
||||
service: service as "tidal" | "qobuz" | "amazon",
|
||||
query,
|
||||
@@ -679,7 +699,8 @@ export function useDownload(region: string) {
|
||||
duration: durationSecondsForFallback,
|
||||
item_id: itemID,
|
||||
audio_format: audioFormat,
|
||||
tidal_variant: service === "tidal" ? getTidalVariant(settings) : undefined,
|
||||
tidal_api_url: service === "tidal" ? customTidalApi : undefined,
|
||||
qobuz_api_url: service === "qobuz" ? customQobuzApi : undefined,
|
||||
spotify_track_number: spotifyTrackNumber,
|
||||
spotify_disc_number: spotifyDiscNumber,
|
||||
spotify_total_tracks: spotifyTotalTracks,
|
||||
@@ -747,6 +768,8 @@ export function useDownload(region: string) {
|
||||
setIsDownloading(true);
|
||||
setBulkDownloadType("selected");
|
||||
setDownloadProgress(0);
|
||||
setDownloadRemainingCount(selectedTracks.length);
|
||||
setCurrentDownloadInfo(null);
|
||||
let outputDir = settings.downloadPath;
|
||||
const os = settings.operatingSystem;
|
||||
const useAlbumTag = settings.folderTemplate?.includes("{album}");
|
||||
@@ -815,7 +838,7 @@ export function useDownload(region: string) {
|
||||
let errorCount = 0;
|
||||
let skippedCount = existingSpotifyIDs.size;
|
||||
const total = selectedTracks.length;
|
||||
setDownloadProgress(Math.round((skippedCount / total) * 100));
|
||||
updateBatchProgress(skippedCount, total);
|
||||
for (let i = 0; i < tracksToDownload.length; i++) {
|
||||
if (shouldStopDownloadRef.current) {
|
||||
toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`);
|
||||
@@ -831,6 +854,10 @@ export function useDownload(region: string) {
|
||||
try {
|
||||
const releaseYear = track.release_date?.substring(0, 4);
|
||||
const response = await downloadWithItemID(settings, itemID, track.name, track.artists, track.album_name, folderName, originalIndex + 1, track.spotify_id, track.duration_ms, isAlbum, releaseYear, track.album_artist || "", track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher);
|
||||
if (response.cancelled || shouldStopDownloadRef.current) {
|
||||
toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`);
|
||||
break;
|
||||
}
|
||||
if (response.success) {
|
||||
if (response.already_exists) {
|
||||
skippedCount++;
|
||||
@@ -868,12 +895,13 @@ export function useDownload(region: string) {
|
||||
}
|
||||
}
|
||||
const completedCount = skippedCount + successCount + errorCount;
|
||||
setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
|
||||
updateBatchProgress(completedCount, total);
|
||||
}
|
||||
setDownloadingTrack(null);
|
||||
setCurrentDownloadInfo(null);
|
||||
setIsDownloading(false);
|
||||
setBulkDownloadType(null);
|
||||
updateBatchProgress(0, 0);
|
||||
shouldStopDownloadRef.current = false;
|
||||
const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App");
|
||||
await CancelAllQueuedItems();
|
||||
@@ -922,6 +950,8 @@ export function useDownload(region: string) {
|
||||
setIsDownloading(true);
|
||||
setBulkDownloadType("all");
|
||||
setDownloadProgress(0);
|
||||
setDownloadRemainingCount(tracksWithId.length);
|
||||
setCurrentDownloadInfo(null);
|
||||
let outputDir = settings.downloadPath;
|
||||
const os = settings.operatingSystem;
|
||||
const useAlbumTag = settings.folderTemplate?.includes("{album}");
|
||||
@@ -985,7 +1015,7 @@ export function useDownload(region: string) {
|
||||
let errorCount = 0;
|
||||
let skippedCount = existingSpotifyIDs.size;
|
||||
const total = tracksWithId.length;
|
||||
setDownloadProgress(Math.round((skippedCount / total) * 100));
|
||||
updateBatchProgress(skippedCount, total);
|
||||
for (let i = 0; i < tracksToDownload.length; i++) {
|
||||
if (shouldStopDownloadRef.current) {
|
||||
toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`);
|
||||
@@ -1001,6 +1031,10 @@ export function useDownload(region: string) {
|
||||
try {
|
||||
const releaseYear = track.release_date?.substring(0, 4);
|
||||
const response = await downloadWithItemID(settings, itemID, track.name, track.artists, track.album_name, folderName, originalIndex + 1, track.spotify_id, track.duration_ms, isAlbum, releaseYear, track.album_artist || "", track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher);
|
||||
if (response.cancelled || shouldStopDownloadRef.current) {
|
||||
toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`);
|
||||
break;
|
||||
}
|
||||
if (response.success) {
|
||||
if (response.already_exists) {
|
||||
skippedCount++;
|
||||
@@ -1035,12 +1069,13 @@ export function useDownload(region: string) {
|
||||
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
const completedCount = skippedCount + successCount + errorCount;
|
||||
setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
|
||||
updateBatchProgress(completedCount, total);
|
||||
}
|
||||
setDownloadingTrack(null);
|
||||
setCurrentDownloadInfo(null);
|
||||
setIsDownloading(false);
|
||||
setBulkDownloadType(null);
|
||||
updateBatchProgress(0, 0);
|
||||
shouldStopDownloadRef.current = false;
|
||||
const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App");
|
||||
await CancelQueued();
|
||||
@@ -1078,6 +1113,15 @@ export function useDownload(region: string) {
|
||||
const handleStopDownload = () => {
|
||||
logger.info("download stopped by user");
|
||||
shouldStopDownloadRef.current = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const { ForceStopDownloads } = await import("../../wailsjs/go/main/App");
|
||||
await ForceStopDownloads();
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Failed to force stop downloads:", err);
|
||||
}
|
||||
})();
|
||||
toast.info("Stopping download...");
|
||||
};
|
||||
const resetDownloadedTracks = () => {
|
||||
@@ -1087,6 +1131,7 @@ export function useDownload(region: string) {
|
||||
};
|
||||
return {
|
||||
downloadProgress,
|
||||
downloadRemainingCount,
|
||||
isDownloading,
|
||||
downloadingTrack,
|
||||
bulkDownloadType,
|
||||
|
||||
@@ -4,12 +4,16 @@ export interface DownloadProgressInfo {
|
||||
is_downloading: boolean;
|
||||
mb_downloaded: number;
|
||||
speed_mbps: number;
|
||||
rate_limited?: boolean;
|
||||
rate_limit_secs?: number;
|
||||
}
|
||||
export function useDownloadProgress() {
|
||||
const [progress, setProgress] = useState<DownloadProgressInfo>({
|
||||
is_downloading: false,
|
||||
mb_downloaded: 0,
|
||||
speed_mbps: 0,
|
||||
rate_limited: false,
|
||||
rate_limit_secs: 0,
|
||||
});
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -9,13 +9,17 @@ const GetTrackISRC = (spotifyId: string): Promise<string> => (window as any)["go
|
||||
async function resolveTemplateISRC(settings: {
|
||||
folderTemplate?: string;
|
||||
filenameTemplate?: string;
|
||||
existingFileCheckMode?: string;
|
||||
}, spotifyId?: string): Promise<string> {
|
||||
if (!spotifyId) {
|
||||
return "";
|
||||
}
|
||||
const folderTemplate = settings.folderTemplate || "";
|
||||
const filenameTemplate = settings.filenameTemplate || "";
|
||||
if (!folderTemplate.includes("{isrc}") && !filenameTemplate.includes("{isrc}")) {
|
||||
const shouldResolveISRC = settings.existingFileCheckMode === "isrc" ||
|
||||
folderTemplate.includes("{isrc}") ||
|
||||
filenameTemplate.includes("{isrc}");
|
||||
if (!shouldResolveISRC) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -159,6 +159,7 @@ export function useMetadata() {
|
||||
info: info,
|
||||
image: image,
|
||||
data: jsonStr,
|
||||
is_explicit: ("track" in data && Boolean(data.track.is_explicit)) || ("album_info" in data && Boolean(data.album_info.is_explicit)),
|
||||
timestamp: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,32 +1,34 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { GetPreviewURL } from "@/../wailsjs/go/main/App";
|
||||
import { SPOTIFY_PREVIEW_VOLUME } from "@/lib/preview";
|
||||
import { getPreviewVolume } from "@/lib/preview";
|
||||
import { createPreviewPlayback, type PreviewPlayback } from "@/lib/preview-player";
|
||||
import { toast } from "sonner";
|
||||
export function usePreview() {
|
||||
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
|
||||
const [currentAudio, setCurrentAudio] = useState<HTMLAudioElement | null>(null);
|
||||
const [playingTrack, setPlayingTrack] = useState<string | null>(null);
|
||||
const currentPlaybackRef = useRef<PreviewPlayback | null>(null);
|
||||
const stopCurrentAudio = () => {
|
||||
if (!currentPlaybackRef.current) {
|
||||
return;
|
||||
}
|
||||
currentPlaybackRef.current.destroy();
|
||||
currentPlaybackRef.current = null;
|
||||
};
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (currentAudio) {
|
||||
currentAudio.pause();
|
||||
currentAudio.currentTime = 0;
|
||||
}
|
||||
stopCurrentAudio();
|
||||
};
|
||||
}, [currentAudio]);
|
||||
}, []);
|
||||
const playPreview = async (trackId: string, trackName: string) => {
|
||||
try {
|
||||
const currentAudio = currentPlaybackRef.current?.audio;
|
||||
if (playingTrack === trackId && currentAudio) {
|
||||
currentAudio.pause();
|
||||
currentAudio.currentTime = 0;
|
||||
stopCurrentAudio();
|
||||
setPlayingTrack(null);
|
||||
setCurrentAudio(null);
|
||||
return;
|
||||
}
|
||||
if (currentAudio) {
|
||||
currentAudio.pause();
|
||||
currentAudio.currentTime = 0;
|
||||
setCurrentAudio(null);
|
||||
stopCurrentAudio();
|
||||
setPlayingTrack(null);
|
||||
}
|
||||
setLoadingPreview(trackId);
|
||||
@@ -38,15 +40,18 @@ export function usePreview() {
|
||||
setLoadingPreview(null);
|
||||
return;
|
||||
}
|
||||
const audio = new Audio(previewURL);
|
||||
audio.volume = SPOTIFY_PREVIEW_VOLUME;
|
||||
const playback = await createPreviewPlayback(previewURL, getPreviewVolume());
|
||||
const audio = playback.audio;
|
||||
audio.addEventListener("loadeddata", () => {
|
||||
setLoadingPreview(null);
|
||||
setPlayingTrack(trackId);
|
||||
});
|
||||
audio.addEventListener("ended", () => {
|
||||
setPlayingTrack(null);
|
||||
setCurrentAudio(null);
|
||||
if (currentPlaybackRef.current?.audio === audio) {
|
||||
currentPlaybackRef.current.destroy();
|
||||
currentPlaybackRef.current = null;
|
||||
}
|
||||
});
|
||||
audio.addEventListener("error", () => {
|
||||
toast.error("Failed to play preview", {
|
||||
@@ -54,27 +59,27 @@ export function usePreview() {
|
||||
});
|
||||
setLoadingPreview(null);
|
||||
setPlayingTrack(null);
|
||||
setCurrentAudio(null);
|
||||
if (currentPlaybackRef.current?.audio === audio) {
|
||||
currentPlaybackRef.current.destroy();
|
||||
currentPlaybackRef.current = null;
|
||||
}
|
||||
});
|
||||
setCurrentAudio(audio);
|
||||
currentPlaybackRef.current = playback;
|
||||
await audio.play();
|
||||
}
|
||||
catch (error: any) {
|
||||
catch (error: unknown) {
|
||||
stopCurrentAudio();
|
||||
console.error("Preview error:", error);
|
||||
toast.error("Preview not available", {
|
||||
description: error?.message || `Could not load preview for "${trackName}"`,
|
||||
description: error instanceof Error ? error.message : `Could not load preview for "${trackName}"`,
|
||||
});
|
||||
setLoadingPreview(null);
|
||||
setPlayingTrack(null);
|
||||
}
|
||||
};
|
||||
const stopPreview = () => {
|
||||
if (currentAudio) {
|
||||
currentAudio.pause();
|
||||
currentAudio.currentTime = 0;
|
||||
setCurrentAudio(null);
|
||||
setPlayingTrack(null);
|
||||
}
|
||||
stopCurrentAudio();
|
||||
setPlayingTrack(null);
|
||||
};
|
||||
return {
|
||||
playPreview,
|
||||
|
||||
@@ -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 {
|
||||
--font-mono: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
+129
-62
@@ -1,4 +1,3 @@
|
||||
import { CheckAPIStatus } from "../../wailsjs/go/main/App";
|
||||
import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout";
|
||||
export type ApiCheckStatus = "checking" | "online" | "offline" | "idle";
|
||||
export interface ApiSource {
|
||||
@@ -10,35 +9,27 @@ export interface ApiSource {
|
||||
interface SpotiFLACNextSource {
|
||||
id: string;
|
||||
name: string;
|
||||
statusKey?: string;
|
||||
statusPrefix?: string;
|
||||
}
|
||||
type SpotiFLACNextStatusResponse = {
|
||||
tidal?: string;
|
||||
qobuz_a?: string;
|
||||
qobuz_b?: string;
|
||||
qobuz_c?: string;
|
||||
deezer_a?: string;
|
||||
deezer_b?: string;
|
||||
amazon_a?: string;
|
||||
amazon_b?: string;
|
||||
amazon_c?: string;
|
||||
apple?: string;
|
||||
};
|
||||
type SpotiFLACNextStatusResponse = Partial<Record<string, string>>;
|
||||
export const API_SOURCES: ApiSource[] = [
|
||||
{ id: "tidal", type: "tidal", name: "Tidal", url: "" },
|
||||
{ id: "qobuz", type: "qobuz", name: "Qobuz", url: "" },
|
||||
{ id: "amazon", type: "amazon", name: "Amazon Music", url: "" },
|
||||
{ id: "musicbrainz", type: "musicbrainz", name: "MusicBrainz", url: "https://musicbrainz.org" },
|
||||
];
|
||||
export const SPOTIFLAC_NEXT_SOURCES: SpotiFLACNextSource[] = [
|
||||
{ id: "tidal", name: "Tidal" },
|
||||
{ id: "qobuz", name: "Qobuz" },
|
||||
{ id: "amazon", name: "Amazon Music" },
|
||||
{ id: "deezer", name: "Deezer" },
|
||||
{ id: "apple", name: "Apple Music" },
|
||||
{ id: "tidal", name: "Tidal", statusPrefix: "tidal_" },
|
||||
{ id: "qobuz", name: "Qobuz", statusPrefix: "qobuz_" },
|
||||
{ id: "amazon", name: "Amazon Music", statusPrefix: "amazon_" },
|
||||
{ id: "deezer", name: "Deezer", statusPrefix: "deezer_" },
|
||||
{ id: "apple", name: "Apple Music", statusKey: "apple" },
|
||||
];
|
||||
const SPOTIFLAC_NEXT_STATUS_URL = "https://status.spotbye.qzz.io/status";
|
||||
const SPOTIFLAC_NEXT_MAX_ATTEMPTS = 3;
|
||||
const SPOTIFLAC_NEXT_RETRY_DELAY_MS = 1200;
|
||||
const SPOTIFLAC_STATUS_URL = "https://gist.githubusercontent.com/afkarxyz/6e57cd362cbd67f889e3a91a76254a5e/raw";
|
||||
const SPOTIFLAC_CURRENT_STATUS_URL = "https://gist.githubusercontent.com/afkarxyz/7e392bc94ec2faaf74ef7d80025636eb/raw";
|
||||
const SPOTIFLAC_STATUS_MAX_ATTEMPTS = 3;
|
||||
const SPOTIFLAC_STATUS_RETRY_DELAY_MS = 1200;
|
||||
const LogStatusConsole = (level: string, message: string): Promise<void> => (window as any)["go"]["main"]["App"]["LogStatusConsole"](level, message);
|
||||
type ApiStatusState = {
|
||||
checkingSources: Record<string, boolean>;
|
||||
statuses: Record<string, ApiCheckStatus>;
|
||||
@@ -49,7 +40,10 @@ let apiStatusState: ApiStatusState = {
|
||||
statuses: {},
|
||||
nextStatuses: {},
|
||||
};
|
||||
let activeCheckCurrentOnly: Promise<void> | null = null;
|
||||
let activeCheckNextOnly: Promise<void> | null = null;
|
||||
let activeStatusPayloadFetch: Promise<SpotiFLACNextStatusResponse> | null = null;
|
||||
let activeCurrentStatusPayloadFetch: Promise<SpotiFLACNextStatusResponse> | null = null;
|
||||
const activeSourceChecks = new Map<string, Promise<void>>();
|
||||
const listeners = new Set<() => void>();
|
||||
function emitApiStatusChange() {
|
||||
@@ -61,23 +55,38 @@ function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState)
|
||||
apiStatusState = updater(apiStatusState);
|
||||
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 {
|
||||
const isOnline = await withTimeout(CheckAPIStatus(source.type, source.url), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.name}`);
|
||||
return isOnline ? "online" : "offline";
|
||||
void LogStatusConsole(level, message);
|
||||
}
|
||||
catch {
|
||||
return "offline";
|
||||
return;
|
||||
}
|
||||
}
|
||||
function statusFromNextValue(value: string | undefined): ApiCheckStatus {
|
||||
return value === "up" ? "online" : "offline";
|
||||
function logStatusError(message: string): void {
|
||||
sendStatusConsole("error", message);
|
||||
}
|
||||
function anyNextVariantUp(values: Array<string | undefined>): ApiCheckStatus {
|
||||
return values.some((value) => value === "up") ? "online" : "offline";
|
||||
}
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
function getNextSourceValues(payload: SpotiFLACNextStatusResponse, source: SpotiFLACNextSource): string[] {
|
||||
if (source.statusKey) {
|
||||
const value = payload[source.statusKey];
|
||||
return typeof value === "string" ? [value] : [];
|
||||
}
|
||||
if (!source.statusPrefix) {
|
||||
return [];
|
||||
}
|
||||
const values: string[] = [];
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (key.startsWith(source.statusPrefix) && typeof value === "string") {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
function getSafeNextStatusesFallback(currentStatuses: Record<string, ApiCheckStatus>): Record<string, ApiCheckStatus> {
|
||||
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
|
||||
@@ -86,40 +95,86 @@ function getSafeNextStatusesFallback(currentStatuses: Record<string, ApiCheckSta
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
async function fetchSpotiFLACNextStatusesOnce(): Promise<Record<string, ApiCheckStatus>> {
|
||||
const response = await withTimeout(fetch(SPOTIFLAC_NEXT_STATUS_URL, {
|
||||
function hasCurrentResults(): boolean {
|
||||
return API_SOURCES.some((source) => {
|
||||
const status = apiStatusState.statuses[source.id];
|
||||
return status === "online" || status === "offline";
|
||||
});
|
||||
}
|
||||
function hasSpotiFLACNextResults(): boolean {
|
||||
return SPOTIFLAC_NEXT_SOURCES.some((source) => {
|
||||
const status = apiStatusState.nextStatuses[source.id];
|
||||
return status === "online" || status === "offline";
|
||||
});
|
||||
}
|
||||
async function fetchStatusPayloadOnce(url: string): Promise<SpotiFLACNextStatusResponse> {
|
||||
const response = await withTimeout(fetch(url, {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
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) {
|
||||
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 {
|
||||
tidal: statusFromNextValue(payload.tidal),
|
||||
qobuz: anyNextVariantUp([payload.qobuz_a, payload.qobuz_b, payload.qobuz_c]),
|
||||
deezer: anyNextVariantUp([payload.deezer_a, payload.deezer_b]),
|
||||
amazon: anyNextVariantUp([payload.amazon_a, payload.amazon_b, payload.amazon_c]),
|
||||
apple: statusFromNextValue(payload.apple),
|
||||
};
|
||||
return (await response.json()) as SpotiFLACNextStatusResponse;
|
||||
}
|
||||
async function checkSpotiFLACNextStatuses(): Promise<Record<string, ApiCheckStatus>> {
|
||||
async function fetchStatusPayloadWithRetry(url: string): Promise<SpotiFLACNextStatusResponse> {
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 1; attempt <= SPOTIFLAC_NEXT_MAX_ATTEMPTS; attempt++) {
|
||||
for (let attempt = 1; attempt <= SPOTIFLAC_STATUS_MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
return await fetchSpotiFLACNextStatusesOnce();
|
||||
return await fetchStatusPayloadOnce(url);
|
||||
}
|
||||
catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < SPOTIFLAC_NEXT_MAX_ATTEMPTS) {
|
||||
await delay(SPOTIFLAC_NEXT_RETRY_DELAY_MS * attempt);
|
||||
if (attempt < SPOTIFLAC_STATUS_MAX_ATTEMPTS) {
|
||||
await delay(SPOTIFLAC_STATUS_RETRY_DELAY_MS * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error("SpotiFLAC Next status check failed");
|
||||
throw lastError instanceof Error ? lastError : new Error("SpotiFLAC status check failed");
|
||||
}
|
||||
async function fetchSpotiFLACStatusPayload(): Promise<SpotiFLACNextStatusResponse> {
|
||||
if (activeStatusPayloadFetch) {
|
||||
return activeStatusPayloadFetch;
|
||||
}
|
||||
activeStatusPayloadFetch = fetchStatusPayloadWithRetry(SPOTIFLAC_STATUS_URL);
|
||||
try {
|
||||
return await activeStatusPayloadFetch;
|
||||
}
|
||||
finally {
|
||||
activeStatusPayloadFetch = null;
|
||||
}
|
||||
}
|
||||
async function fetchSpotiFLACCurrentStatusPayload(): Promise<SpotiFLACNextStatusResponse> {
|
||||
if (activeCurrentStatusPayloadFetch) {
|
||||
return activeCurrentStatusPayloadFetch;
|
||||
}
|
||||
activeCurrentStatusPayloadFetch = fetchStatusPayloadWithRetry(SPOTIFLAC_CURRENT_STATUS_URL);
|
||||
try {
|
||||
return await activeCurrentStatusPayloadFetch;
|
||||
}
|
||||
finally {
|
||||
activeCurrentStatusPayloadFetch = null;
|
||||
}
|
||||
}
|
||||
async function checkSourceStatus(source: ApiSource): Promise<ApiCheckStatus> {
|
||||
try {
|
||||
const payload = await fetchSpotiFLACCurrentStatusPayload();
|
||||
return payload[source.id] === "up" ? "online" : "offline";
|
||||
}
|
||||
catch (error) {
|
||||
logStatusError(`[Status][${source.name}] Status check failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return "offline";
|
||||
}
|
||||
}
|
||||
async function checkSpotiFLACNextStatuses(): Promise<Record<string, ApiCheckStatus>> {
|
||||
const payload = await fetchSpotiFLACStatusPayload();
|
||||
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
|
||||
acc[source.id] = anyNextVariantUp(getNextSourceValues(payload, source));
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
export function getApiStatusState(): ApiStatusState {
|
||||
return apiStatusState;
|
||||
@@ -130,11 +185,19 @@ export function subscribeApiStatus(listener: () => void): () => void {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
function hasSpotiFLACNextResults(): boolean {
|
||||
return SPOTIFLAC_NEXT_SOURCES.some((source) => {
|
||||
const status = apiStatusState.nextStatuses[source.id];
|
||||
return status === "online" || status === "offline";
|
||||
});
|
||||
export async function checkCurrentApiStatusesOnly(): Promise<void> {
|
||||
if (activeCheckCurrentOnly) {
|
||||
return activeCheckCurrentOnly;
|
||||
}
|
||||
activeCheckCurrentOnly = (async () => {
|
||||
await Promise.all(API_SOURCES.map((source) => checkApiStatus(source.id)));
|
||||
})();
|
||||
try {
|
||||
await activeCheckCurrentOnly;
|
||||
}
|
||||
finally {
|
||||
activeCheckCurrentOnly = null;
|
||||
}
|
||||
}
|
||||
export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
|
||||
if (activeCheckNextOnly) {
|
||||
@@ -150,10 +213,6 @@ export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
|
||||
},
|
||||
}));
|
||||
try {
|
||||
setApiStatusState((current) => ({
|
||||
...current,
|
||||
nextStatuses: { ...current.nextStatuses },
|
||||
}));
|
||||
const nextStatuses = await checkSpotiFLACNextStatuses();
|
||||
setApiStatusState((current) => ({
|
||||
...current,
|
||||
@@ -169,17 +228,25 @@ export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
|
||||
nextStatuses: getSafeNextStatusesFallback(current.nextStatuses),
|
||||
}));
|
||||
}
|
||||
finally {
|
||||
activeCheckNextOnly = null;
|
||||
}
|
||||
})();
|
||||
return activeCheckNextOnly;
|
||||
try {
|
||||
await activeCheckNextOnly;
|
||||
}
|
||||
finally {
|
||||
activeCheckNextOnly = null;
|
||||
}
|
||||
}
|
||||
export function ensureSpotiFLACNextStatusCheckStarted(): void {
|
||||
export function ensureApiStatusCheckStarted(): void {
|
||||
if (!activeCheckCurrentOnly && !hasCurrentResults()) {
|
||||
void checkCurrentApiStatusesOnly();
|
||||
}
|
||||
if (!activeCheckNextOnly && !hasSpotiFLACNextResults()) {
|
||||
void checkSpotiFLACNextStatusesOnly();
|
||||
}
|
||||
}
|
||||
export function ensureSpotiFLACNextStatusCheckStarted(): void {
|
||||
ensureApiStatusCheckStarted();
|
||||
}
|
||||
export async function checkApiStatus(sourceId: string): Promise<void> {
|
||||
const source = API_SOURCES.find((item) => item.id === sourceId);
|
||||
if (!source) {
|
||||
|
||||
@@ -13,9 +13,6 @@ export async function fetchSpotifyMetadata(url: string, batch: boolean = true, d
|
||||
}
|
||||
export async function downloadTrack(request: DownloadRequest): Promise<DownloadResponse> {
|
||||
const req = new main.DownloadRequest(request);
|
||||
if (request.tidal_variant !== undefined) {
|
||||
(req as any).tidal_variant = request.tidal_variant;
|
||||
}
|
||||
if (request.use_single_genre !== undefined) {
|
||||
(req as any).use_single_genre = request.use_single_genre;
|
||||
}
|
||||
|
||||
@@ -40,3 +40,6 @@ export function buildClickableArtists(artists: string, artistsData?: ArtistSimpl
|
||||
};
|
||||
});
|
||||
}
|
||||
export function getClickableArtistKey(artist: ClickableArtist) {
|
||||
return artist.id || artist.external_urls || artist.name;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { getPreviewVolume, PREVIEW_VOLUME_CHANGED_EVENT } from "@/lib/preview";
|
||||
export interface PreviewPlayback {
|
||||
audio: HTMLAudioElement;
|
||||
destroy: () => void;
|
||||
}
|
||||
export async function createPreviewPlayback(url: string, volume: number): Promise<PreviewPlayback> {
|
||||
const audio = new Audio(url);
|
||||
const applyVolume = (nextVolume: number) => {
|
||||
if (!Number.isFinite(nextVolume)) {
|
||||
return;
|
||||
}
|
||||
audio.volume = Math.min(1, Math.max(0, nextVolume));
|
||||
};
|
||||
applyVolume(volume);
|
||||
const handleSettingsUpdated = () => {
|
||||
applyVolume(getPreviewVolume());
|
||||
};
|
||||
const handlePreviewVolumeChanged = (event: Event) => {
|
||||
const nextVolumePercent = (event as CustomEvent<number>).detail;
|
||||
if (!Number.isFinite(nextVolumePercent)) {
|
||||
return;
|
||||
}
|
||||
applyVolume(nextVolumePercent / 100);
|
||||
};
|
||||
window.addEventListener("settingsUpdated", handleSettingsUpdated);
|
||||
window.addEventListener(PREVIEW_VOLUME_CHANGED_EVENT, handlePreviewVolumeChanged);
|
||||
return {
|
||||
audio,
|
||||
destroy: () => {
|
||||
window.removeEventListener("settingsUpdated", handleSettingsUpdated);
|
||||
window.removeEventListener(PREVIEW_VOLUME_CHANGED_EVENT, handlePreviewVolumeChanged);
|
||||
audio.pause();
|
||||
audio.removeAttribute("src");
|
||||
audio.load();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1 +1,10 @@
|
||||
import { getSettings } from "@/lib/settings";
|
||||
export const SPOTIFY_PREVIEW_VOLUME = 1;
|
||||
export const PREVIEW_VOLUME_CHANGED_EVENT = "previewVolumeChanged";
|
||||
export function getPreviewVolume(): number {
|
||||
const previewVolume = getSettings().previewVolume;
|
||||
if (!Number.isFinite(previewVolume)) {
|
||||
return SPOTIFY_PREVIEW_VOLUME;
|
||||
}
|
||||
return Math.min(1, Math.max(0, previewVolume / 100));
|
||||
}
|
||||
|
||||
+614
-238
@@ -1,15 +1,33 @@
|
||||
import { GetDefaults, LoadSettings, SaveSettings as SaveToBackend } from "../../wailsjs/go/main/App";
|
||||
export type FontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk" | "noto-sans" | "nunito-sans" | "figtree" | "raleway" | "public-sans" | "outfit" | "jetbrains-mono" | "geist-sans" | "bricolage-grotesque";
|
||||
import { GetDefaults, LoadFonts as LoadFontsFromBackend, LoadSettings, SaveFonts as SaveFontsToBackend, SaveSettings as SaveToBackend, } from "../../wailsjs/go/main/App";
|
||||
export type BuiltInFontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk" | "noto-sans" | "nunito-sans" | "figtree" | "raleway" | "public-sans" | "outfit" | "jetbrains-mono" | "geist-sans" | "bricolage-grotesque";
|
||||
export type CustomFontFamily = `custom-${string}`;
|
||||
export type FontFamily = BuiltInFontFamily | CustomFontFamily;
|
||||
export interface CustomFontOption {
|
||||
value: CustomFontFamily;
|
||||
label: string;
|
||||
fontFamily: string;
|
||||
url: string;
|
||||
}
|
||||
export type FontOption = {
|
||||
value: FontFamily;
|
||||
label: string;
|
||||
fontFamily: string;
|
||||
url?: string;
|
||||
};
|
||||
export type FolderPreset = "none" | "artist" | "album" | "year-album" | "year-artist-album" | "artist-album" | "artist-year-album" | "artist-year-nested-album" | "album-artist" | "album-artist-album" | "album-artist-year-album" | "album-artist-year-nested-album" | "year" | "year-artist" | "custom";
|
||||
export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "title-album-artist" | "track-title-album-artist" | "artist-album-title" | "track-dash-title" | "disc-track-title" | "disc-track-title-artist" | "custom";
|
||||
export type ExistingFileCheckMode = "filename" | "isrc";
|
||||
export interface Settings {
|
||||
downloadPath: string;
|
||||
downloader: "auto" | "tidal" | "qobuz" | "amazon";
|
||||
customTidalApi: string;
|
||||
customQobuzApi: string;
|
||||
linkResolver: "songstats" | "songlink";
|
||||
allowResolverFallback: boolean;
|
||||
theme: string;
|
||||
themeMode: "auto" | "light" | "dark";
|
||||
fontFamily: FontFamily;
|
||||
customFonts: CustomFontOption[];
|
||||
folderPreset: FolderPreset;
|
||||
folderTemplate: string;
|
||||
filenamePreset: FilenamePreset;
|
||||
@@ -22,16 +40,17 @@ export interface Settings {
|
||||
embedLyrics: boolean;
|
||||
embedMaxQualityCover: boolean;
|
||||
operatingSystem: "Windows" | "linux/MacOS";
|
||||
tidalVariant: "tidal" | "alt";
|
||||
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
|
||||
qobuzQuality: "6" | "7" | "27";
|
||||
amazonQuality: "original";
|
||||
amazonQuality: "16" | "24";
|
||||
autoOrder: "tidal-qobuz-amazon" | "tidal-amazon-qobuz" | "qobuz-tidal-amazon" | "qobuz-amazon-tidal" | "amazon-tidal-qobuz" | "amazon-qobuz-tidal" | string;
|
||||
autoQuality: "16" | "24";
|
||||
allowFallback: boolean;
|
||||
createPlaylistFolder: boolean;
|
||||
playlistOwnerFolderName: boolean;
|
||||
createM3u8File: boolean;
|
||||
previewVolume: number;
|
||||
existingFileCheckMode: ExistingFileCheckMode;
|
||||
useFirstArtistOnly: boolean;
|
||||
useSingleGenre: boolean;
|
||||
embedGenre: boolean;
|
||||
@@ -42,54 +61,105 @@ export const FOLDER_PRESETS: Record<FolderPreset, {
|
||||
label: string;
|
||||
template: string;
|
||||
}> = {
|
||||
"none": { label: "No Subfolder", template: "" },
|
||||
"artist": { label: "Artist", template: "{artist}" },
|
||||
"album": { label: "Album", template: "{album}" },
|
||||
none: { label: "No Subfolder", template: "" },
|
||||
artist: { label: "Artist", template: "{artist}" },
|
||||
album: { label: "Album", template: "{album}" },
|
||||
"year-album": { label: "[Year] Album", template: "[{year}] {album}" },
|
||||
"year-artist-album": { label: "[Year] Artist - Album", template: "[{year}] {artist} - {album}" },
|
||||
"year-artist-album": {
|
||||
label: "[Year] Artist - Album",
|
||||
template: "[{year}] {artist} - {album}",
|
||||
},
|
||||
"artist-album": { label: "Artist / Album", template: "{artist}/{album}" },
|
||||
"artist-year-album": { label: "Artist / [Year] Album", template: "{artist}/[{year}] {album}" },
|
||||
"artist-year-nested-album": { label: "Artist / Year / Album", template: "{artist}/{year}/{album}" },
|
||||
"artist-year-album": {
|
||||
label: "Artist / [Year] Album",
|
||||
template: "{artist}/[{year}] {album}",
|
||||
},
|
||||
"artist-year-nested-album": {
|
||||
label: "Artist / Year / Album",
|
||||
template: "{artist}/{year}/{album}",
|
||||
},
|
||||
"album-artist": { label: "Album Artist", template: "{album_artist}" },
|
||||
"album-artist-album": { label: "Album Artist / Album", template: "{album_artist}/{album}" },
|
||||
"album-artist-year-album": { label: "Album Artist / [Year] Album", template: "{album_artist}/[{year}] {album}" },
|
||||
"album-artist-year-nested-album": { label: "Album Artist / Year / Album", template: "{album_artist}/{year}/{album}" },
|
||||
"year": { label: "Year", template: "{year}" },
|
||||
"album-artist-album": {
|
||||
label: "Album Artist / Album",
|
||||
template: "{album_artist}/{album}",
|
||||
},
|
||||
"album-artist-year-album": {
|
||||
label: "Album Artist / [Year] Album",
|
||||
template: "{album_artist}/[{year}] {album}",
|
||||
},
|
||||
"album-artist-year-nested-album": {
|
||||
label: "Album Artist / Year / Album",
|
||||
template: "{album_artist}/{year}/{album}",
|
||||
},
|
||||
year: { label: "Year", template: "{year}" },
|
||||
"year-artist": { label: "Year / Artist", template: "{year}/{artist}" },
|
||||
"custom": { label: "Custom...", template: "{artist}/{album}" },
|
||||
custom: { label: "Custom...", template: "{artist}/{album}" },
|
||||
};
|
||||
export const FILENAME_PRESETS: Record<FilenamePreset, {
|
||||
label: string;
|
||||
template: string;
|
||||
}> = {
|
||||
"title": { label: "Title", template: "{title}" },
|
||||
title: { label: "Title", template: "{title}" },
|
||||
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
|
||||
"artist-title": { label: "Artist - Title", template: "{artist} - {title}" },
|
||||
"track-title": { label: "Track. Title", template: "{track}. {title}" },
|
||||
"track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" },
|
||||
"track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" },
|
||||
"title-album-artist": { label: "Title - Album Artist", template: "{title} - {album_artist}" },
|
||||
"track-title-album-artist": { label: "Track. Title - Album Artist", template: "{track}. {title} - {album_artist}" },
|
||||
"artist-album-title": { label: "Artist - Album - Title", template: "{artist} - {album} - {title}" },
|
||||
"track-title-artist": {
|
||||
label: "Track. Title - Artist",
|
||||
template: "{track}. {title} - {artist}",
|
||||
},
|
||||
"track-artist-title": {
|
||||
label: "Track. Artist - Title",
|
||||
template: "{track}. {artist} - {title}",
|
||||
},
|
||||
"title-album-artist": {
|
||||
label: "Title - Album Artist",
|
||||
template: "{title} - {album_artist}",
|
||||
},
|
||||
"track-title-album-artist": {
|
||||
label: "Track. Title - Album Artist",
|
||||
template: "{track}. {title} - {album_artist}",
|
||||
},
|
||||
"artist-album-title": {
|
||||
label: "Artist - Album - Title",
|
||||
template: "{artist} - {album} - {title}",
|
||||
},
|
||||
"track-dash-title": { label: "Track - Title", template: "{track} - {title}" },
|
||||
"disc-track-title": { label: "Disc-Track. Title", template: "{disc}-{track}. {title}" },
|
||||
"disc-track-title-artist": { label: "Disc-Track. Title - Artist", template: "{disc}-{track}. {title} - {artist}" },
|
||||
"custom": { label: "Custom...", template: "{title} - {artist}" },
|
||||
"disc-track-title": {
|
||||
label: "Disc-Track. Title",
|
||||
template: "{disc}-{track}. {title}",
|
||||
},
|
||||
"disc-track-title-artist": {
|
||||
label: "Disc-Track. Title - Artist",
|
||||
template: "{disc}-{track}. {title} - {artist}",
|
||||
},
|
||||
custom: { label: "Custom...", template: "{title} - {artist}" },
|
||||
};
|
||||
export const TEMPLATE_VARIABLES = [
|
||||
{ key: "{title}", description: "Track title", example: "Shake It Off" },
|
||||
{ key: "{artist}", description: "Track artist", example: "Taylor Swift" },
|
||||
{ key: "{album}", description: "Album name", example: "1989" },
|
||||
{ key: "{album_artist}", description: "Album artist", example: "Taylor Swift" },
|
||||
{
|
||||
key: "{album_artist}",
|
||||
description: "Album artist",
|
||||
example: "Taylor Swift",
|
||||
},
|
||||
{ key: "{track}", description: "Track number", example: "01" },
|
||||
{ key: "{disc}", description: "Disc number", example: "1" },
|
||||
{ key: "{year}", description: "Release year", example: "2014" },
|
||||
{ key: "{date}", description: "Release date (YYYY-MM-DD)", example: "2014-10-27" },
|
||||
{ key: "{isrc}", description: "Track ISRC", example: "USUM71412345" },
|
||||
{
|
||||
key: "{date}",
|
||||
description: "Release date (YYYY-MM-DD)",
|
||||
example: "2014-10-27",
|
||||
},
|
||||
{
|
||||
key: "{isrc}",
|
||||
description: "Track ISRC",
|
||||
example: "USUM71412345",
|
||||
},
|
||||
];
|
||||
function detectOS(): "Windows" | "linux/MacOS" {
|
||||
const platform = window.navigator.platform.toLowerCase();
|
||||
if (platform.includes('win')) {
|
||||
if (platform.includes("win")) {
|
||||
return "Windows";
|
||||
}
|
||||
return "linux/MacOS";
|
||||
@@ -97,11 +167,14 @@ function detectOS(): "Windows" | "linux/MacOS" {
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
downloadPath: "",
|
||||
downloader: "auto",
|
||||
customTidalApi: "",
|
||||
customQobuzApi: "",
|
||||
linkResolver: "songlink",
|
||||
allowResolverFallback: true,
|
||||
theme: "yellow",
|
||||
themeMode: "auto",
|
||||
fontFamily: "google-sans",
|
||||
customFonts: [],
|
||||
folderPreset: "none",
|
||||
folderTemplate: "",
|
||||
filenamePreset: "title-artist",
|
||||
@@ -111,52 +184,506 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
embedLyrics: false,
|
||||
embedMaxQualityCover: false,
|
||||
operatingSystem: detectOS(),
|
||||
tidalVariant: "tidal",
|
||||
tidalQuality: "LOSSLESS",
|
||||
qobuzQuality: "6",
|
||||
amazonQuality: "original",
|
||||
amazonQuality: "16",
|
||||
autoOrder: "tidal-qobuz-amazon",
|
||||
autoQuality: "16",
|
||||
allowFallback: true,
|
||||
createPlaylistFolder: true,
|
||||
playlistOwnerFolderName: false,
|
||||
createM3u8File: false,
|
||||
previewVolume: 100,
|
||||
existingFileCheckMode: "filename",
|
||||
useFirstArtistOnly: false,
|
||||
useSingleGenre: false,
|
||||
embedGenre: false,
|
||||
redownloadWithSuffix: false,
|
||||
separator: "semicolon"
|
||||
separator: "semicolon",
|
||||
};
|
||||
export const FONT_OPTIONS: {
|
||||
value: FontFamily;
|
||||
label: string;
|
||||
fontFamily: string;
|
||||
}[] = [
|
||||
{ value: "bricolage-grotesque", label: "Bricolage Grotesque", fontFamily: '"Bricolage Grotesque", system-ui, sans-serif' },
|
||||
{ value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' },
|
||||
{ value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
|
||||
{ value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
|
||||
{ value: "google-sans", label: "Google Sans", fontFamily: '"Google Sans", system-ui, sans-serif' },
|
||||
{ value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' },
|
||||
{ value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' },
|
||||
{ value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' },
|
||||
{ value: "noto-sans", label: "Noto Sans", fontFamily: '"Noto Sans", system-ui, sans-serif' },
|
||||
{ value: "nunito-sans", label: "Nunito Sans", fontFamily: '"Nunito Sans", system-ui, sans-serif' },
|
||||
{ value: "outfit", label: "Outfit", fontFamily: '"Outfit", system-ui, sans-serif' },
|
||||
{ value: "plus-jakarta-sans", label: "Plus Jakarta Sans", fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif' },
|
||||
{ value: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' },
|
||||
{ value: "public-sans", label: "Public Sans", fontFamily: '"Public Sans", system-ui, sans-serif' },
|
||||
{ value: "raleway", label: "Raleway", fontFamily: '"Raleway", system-ui, sans-serif' },
|
||||
{ value: "roboto", label: "Roboto", fontFamily: '"Roboto", system-ui, sans-serif' },
|
||||
{ value: "space-grotesk", label: "Space Grotesk", fontFamily: '"Space Grotesk", system-ui, sans-serif' },
|
||||
export const FONT_OPTIONS: FontOption[] = [
|
||||
{
|
||||
value: "bricolage-grotesque",
|
||||
label: "Bricolage Grotesque",
|
||||
fontFamily: '"Bricolage Grotesque", system-ui, sans-serif',
|
||||
},
|
||||
{
|
||||
value: "dm-sans",
|
||||
label: "DM Sans",
|
||||
fontFamily: '"DM Sans", system-ui, sans-serif',
|
||||
},
|
||||
{
|
||||
value: "figtree",
|
||||
label: "Figtree",
|
||||
fontFamily: '"Figtree", system-ui, sans-serif',
|
||||
},
|
||||
{
|
||||
value: "geist-sans",
|
||||
label: "Geist Sans",
|
||||
fontFamily: '"Geist", system-ui, sans-serif',
|
||||
},
|
||||
{
|
||||
value: "google-sans",
|
||||
label: "Google Sans",
|
||||
fontFamily: '"Google Sans", system-ui, sans-serif',
|
||||
},
|
||||
{
|
||||
value: "inter",
|
||||
label: "Inter",
|
||||
fontFamily: '"Inter", system-ui, sans-serif',
|
||||
},
|
||||
{
|
||||
value: "jetbrains-mono",
|
||||
label: "JetBrains Mono",
|
||||
fontFamily: '"JetBrains Mono", ui-monospace, monospace',
|
||||
},
|
||||
{
|
||||
value: "manrope",
|
||||
label: "Manrope",
|
||||
fontFamily: '"Manrope", system-ui, sans-serif',
|
||||
},
|
||||
{
|
||||
value: "noto-sans",
|
||||
label: "Noto Sans",
|
||||
fontFamily: '"Noto Sans", system-ui, sans-serif',
|
||||
},
|
||||
{
|
||||
value: "nunito-sans",
|
||||
label: "Nunito Sans",
|
||||
fontFamily: '"Nunito Sans", system-ui, sans-serif',
|
||||
},
|
||||
{
|
||||
value: "outfit",
|
||||
label: "Outfit",
|
||||
fontFamily: '"Outfit", system-ui, sans-serif',
|
||||
},
|
||||
{
|
||||
value: "plus-jakarta-sans",
|
||||
label: "Plus Jakarta Sans",
|
||||
fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif',
|
||||
},
|
||||
{
|
||||
value: "poppins",
|
||||
label: "Poppins",
|
||||
fontFamily: '"Poppins", system-ui, sans-serif',
|
||||
},
|
||||
{
|
||||
value: "public-sans",
|
||||
label: "Public Sans",
|
||||
fontFamily: '"Public Sans", system-ui, sans-serif',
|
||||
},
|
||||
{
|
||||
value: "raleway",
|
||||
label: "Raleway",
|
||||
fontFamily: '"Raleway", system-ui, sans-serif',
|
||||
},
|
||||
{
|
||||
value: "roboto",
|
||||
label: "Roboto",
|
||||
fontFamily: '"Roboto", system-ui, sans-serif',
|
||||
},
|
||||
{
|
||||
value: "space-grotesk",
|
||||
label: "Space Grotesk",
|
||||
fontFamily: '"Space Grotesk", system-ui, sans-serif',
|
||||
},
|
||||
];
|
||||
export function applyFont(fontFamily: FontFamily): void {
|
||||
const font = FONT_OPTIONS.find(f => f.value === fontFamily);
|
||||
const BUILT_IN_FONT_VALUES = new Set(FONT_OPTIONS.map((font) => font.value));
|
||||
const GOOGLE_FONT_LINK_ID_PREFIX = "spotiflac-custom-font-";
|
||||
const GOOGLE_FONTS_CSS_HOST = "fonts.googleapis.com";
|
||||
const GOOGLE_FONTS_SPECIMEN_HOST = "fonts.google.com";
|
||||
const SETTINGS_KEY = "spotiflac-settings";
|
||||
let cachedSettings: Settings | null = null;
|
||||
type SettingsPayload = Partial<Settings> & {
|
||||
darkMode?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
const KNOWN_SETTINGS_KEYS = Object.keys(DEFAULT_SETTINGS) as Array<keyof Settings>;
|
||||
function extractGoogleFontInputUrl(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
const hrefMatch = trimmed.match(/\bhref=["']([^"']+)["']/i);
|
||||
if (hrefMatch?.[1]) {
|
||||
return hrefMatch[1];
|
||||
}
|
||||
const importMatch = trimmed.match(/@import\s+url\(["']?([^"')]+)["']?\)/i);
|
||||
if (importMatch?.[1]) {
|
||||
return importMatch[1];
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
function coerceGoogleFontUrl(rawUrl: string): string {
|
||||
const trimmed = rawUrl.trim();
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
if (/^(fonts\.googleapis\.com|fonts\.google\.com)\//i.test(trimmed)) {
|
||||
return `https://${trimmed}`;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
function normalizeFontLabel(label: string): string {
|
||||
return label.replace(/\+/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
function slugifyFontLabel(label: string): string {
|
||||
return label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "font";
|
||||
}
|
||||
function toFontFamilyCss(label: string): string {
|
||||
const escapedLabel = label.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
return `"${escapedLabel}", system-ui, sans-serif`;
|
||||
}
|
||||
function buildGoogleFontsCssUrl(label: string): string {
|
||||
const url = new URL("https://fonts.googleapis.com/css2");
|
||||
url.searchParams.set("family", label);
|
||||
url.searchParams.set("display", "swap");
|
||||
return url.toString();
|
||||
}
|
||||
function extractSpecimenFontLabel(parsed: URL): string {
|
||||
const segments = parsed.pathname.split("/").filter(Boolean);
|
||||
const specimenIndex = segments.findIndex((segment) => segment.toLowerCase() === "specimen");
|
||||
const specimenName = specimenIndex >= 0 ? segments[specimenIndex + 1] : "";
|
||||
return normalizeFontLabel(decodeURIComponent(specimenName || ""));
|
||||
}
|
||||
function normalizeGoogleFontCssUrl(rawUrl: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(coerceGoogleFontUrl(extractGoogleFontInputUrl(rawUrl)));
|
||||
if (parsed.protocol !== "https:") {
|
||||
return null;
|
||||
}
|
||||
if (parsed.hostname === GOOGLE_FONTS_SPECIMEN_HOST) {
|
||||
const label = extractSpecimenFontLabel(parsed);
|
||||
return label ? buildGoogleFontsCssUrl(label) : null;
|
||||
}
|
||||
if (parsed.hostname !== GOOGLE_FONTS_CSS_HOST ||
|
||||
(parsed.pathname !== "/css" && parsed.pathname !== "/css2")) {
|
||||
return null;
|
||||
}
|
||||
if (parsed.searchParams.getAll("family").length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!parsed.searchParams.has("display")) {
|
||||
parsed.searchParams.set("display", "swap");
|
||||
}
|
||||
return parsed.toString();
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export function parseGoogleFontUrl(rawUrl: string): CustomFontOption | null {
|
||||
const normalizedUrl = normalizeGoogleFontCssUrl(rawUrl);
|
||||
if (!normalizedUrl) {
|
||||
return null;
|
||||
}
|
||||
const parsed = new URL(normalizedUrl);
|
||||
const family = parsed.searchParams.getAll("family")[0];
|
||||
const label = normalizeFontLabel((family || "").split(":")[0] || "");
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
value: `custom-${slugifyFontLabel(label)}` as CustomFontFamily,
|
||||
label,
|
||||
fontFamily: toFontFamilyCss(label),
|
||||
url: normalizedUrl,
|
||||
};
|
||||
}
|
||||
function normalizeCustomFonts(customFonts: unknown): CustomFontOption[] {
|
||||
if (!Array.isArray(customFonts)) {
|
||||
return [];
|
||||
}
|
||||
const normalizedFonts: CustomFontOption[] = [];
|
||||
const seenValues = new Set<string>();
|
||||
const seenUrls = new Set<string>();
|
||||
for (const item of customFonts) {
|
||||
if (!item || typeof item !== "object") {
|
||||
continue;
|
||||
}
|
||||
const rawUrl = (item as {
|
||||
url?: unknown;
|
||||
}).url;
|
||||
if (typeof rawUrl !== "string") {
|
||||
continue;
|
||||
}
|
||||
const parsed = parseGoogleFontUrl(rawUrl);
|
||||
if (!parsed || seenValues.has(parsed.value) || seenUrls.has(parsed.url)) {
|
||||
continue;
|
||||
}
|
||||
seenValues.add(parsed.value);
|
||||
seenUrls.add(parsed.url);
|
||||
normalizedFonts.push(parsed);
|
||||
}
|
||||
return normalizedFonts;
|
||||
}
|
||||
function normalizeFontFamily(fontFamily: unknown, customFonts: CustomFontOption[]): FontFamily {
|
||||
if (typeof fontFamily !== "string") {
|
||||
return DEFAULT_SETTINGS.fontFamily;
|
||||
}
|
||||
if (BUILT_IN_FONT_VALUES.has(fontFamily as BuiltInFontFamily)) {
|
||||
return fontFamily as BuiltInFontFamily;
|
||||
}
|
||||
const customFont = customFonts.find((font) => font.value === fontFamily);
|
||||
return customFont ? customFont.value : DEFAULT_SETTINGS.fontFamily;
|
||||
}
|
||||
export function getFontOptions(customFonts: CustomFontOption[] = []): FontOption[] {
|
||||
return [...FONT_OPTIONS, ...normalizeCustomFonts(customFonts)];
|
||||
}
|
||||
export function loadGoogleFontUrl(url: string, id = `${GOOGLE_FONT_LINK_ID_PREFIX}preview`): void {
|
||||
const normalizedUrl = normalizeGoogleFontCssUrl(url);
|
||||
if (!normalizedUrl) {
|
||||
return;
|
||||
}
|
||||
let link = document.getElementById(id) as HTMLLinkElement | null;
|
||||
if (!link) {
|
||||
link = document.createElement("link");
|
||||
link.id = id;
|
||||
link.rel = "stylesheet";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
if (link.href !== normalizedUrl) {
|
||||
link.href = normalizedUrl;
|
||||
}
|
||||
}
|
||||
function loadCustomFontStylesheets(customFonts: CustomFontOption[]): void {
|
||||
for (const font of normalizeCustomFonts(customFonts)) {
|
||||
loadGoogleFontUrl(font.url, `${GOOGLE_FONT_LINK_ID_PREFIX}${font.value}`);
|
||||
}
|
||||
}
|
||||
export function applyFont(fontFamily: FontFamily, customFonts: CustomFontOption[] = []): void {
|
||||
const fontOptions = getFontOptions(customFonts);
|
||||
loadCustomFontStylesheets(customFonts);
|
||||
const font = fontOptions.find((option) => option.value === fontFamily) ||
|
||||
FONT_OPTIONS.find((option) => option.value === DEFAULT_SETTINGS.fontFamily);
|
||||
if (font) {
|
||||
document.documentElement.style.setProperty('--font-sans', font.fontFamily);
|
||||
document.documentElement.style.setProperty("--font-sans", font.fontFamily);
|
||||
document.body.style.fontFamily = font.fontFamily;
|
||||
}
|
||||
}
|
||||
async function persistCustomFontsInternal(customFonts: CustomFontOption[]): Promise<CustomFontOption[]> {
|
||||
const normalizedFonts = normalizeCustomFonts(customFonts);
|
||||
await SaveFontsToBackend(normalizedFonts as unknown as Array<Record<string, unknown>>);
|
||||
if (cachedSettings) {
|
||||
cachedSettings = toNormalizedSettings({
|
||||
...cachedSettings,
|
||||
customFonts: normalizedFonts,
|
||||
});
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(cachedSettings));
|
||||
window.dispatchEvent(new CustomEvent("settingsUpdated", { detail: cachedSettings }));
|
||||
}
|
||||
return normalizedFonts;
|
||||
}
|
||||
async function loadStoredCustomFonts(fallbackFonts?: unknown): Promise<CustomFontOption[]> {
|
||||
try {
|
||||
const storedFonts = await LoadFontsFromBackend();
|
||||
if (storedFonts !== null) {
|
||||
return normalizeCustomFonts(storedFonts);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to load custom fonts:", error);
|
||||
}
|
||||
const migratedFonts = normalizeCustomFonts(fallbackFonts);
|
||||
if (migratedFonts.length > 0) {
|
||||
try {
|
||||
return await persistCustomFontsInternal(migratedFonts);
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to migrate custom fonts:", error);
|
||||
}
|
||||
}
|
||||
return migratedFonts;
|
||||
}
|
||||
export async function loadCustomFonts(): Promise<CustomFontOption[]> {
|
||||
return loadStoredCustomFonts(getSettings().customFonts);
|
||||
}
|
||||
export async function saveCustomFonts(customFonts: CustomFontOption[]): Promise<CustomFontOption[]> {
|
||||
return persistCustomFontsInternal(customFonts);
|
||||
}
|
||||
function keepKnownSettings(settings: SettingsPayload): SettingsPayload {
|
||||
const normalized: Record<string, unknown> = {};
|
||||
for (const key of KNOWN_SETTINGS_KEYS) {
|
||||
if (key in settings) {
|
||||
normalized[key] = settings[key];
|
||||
}
|
||||
}
|
||||
return normalized as SettingsPayload;
|
||||
}
|
||||
function normalizePreviewVolume(volume: unknown): number {
|
||||
const parsed = typeof volume === "number"
|
||||
? volume
|
||||
: typeof volume === "string"
|
||||
? Number.parseFloat(volume)
|
||||
: Number.NaN;
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DEFAULT_SETTINGS.previewVolume;
|
||||
}
|
||||
return Math.min(100, Math.max(0, Math.round(parsed)));
|
||||
}
|
||||
function normalizeCustomTidalApi(value: unknown): string {
|
||||
return typeof value === "string"
|
||||
? value.trim().replace(/\/+$/g, "")
|
||||
: "";
|
||||
}
|
||||
export function hasConfiguredCustomTidalApi(value: unknown): boolean {
|
||||
return normalizeCustomTidalApi(value).startsWith("https://");
|
||||
}
|
||||
function normalizeCustomQobuzApi(value: unknown): string {
|
||||
return typeof value === "string"
|
||||
? value.trim().replace(/\/+$/g, "")
|
||||
: "";
|
||||
}
|
||||
export function hasConfiguredCustomQobuzApi(value: unknown): boolean {
|
||||
return normalizeCustomQobuzApi(value).startsWith("https://");
|
||||
}
|
||||
export function sanitizeAutoOrder(order: unknown): string {
|
||||
const allowedServices = new Set(["tidal", "qobuz", "amazon"]);
|
||||
const fallbackOrder = "tidal-qobuz-amazon";
|
||||
if (typeof order !== "string") {
|
||||
return fallbackOrder;
|
||||
}
|
||||
const normalized = order
|
||||
.split("-")
|
||||
.map((part) => part.trim().toLowerCase())
|
||||
.filter((part, index, parts) => part !== "" && allowedServices.has(part) && parts.indexOf(part) === index);
|
||||
return normalized.length >= 2 ? normalized.join("-") : fallbackOrder;
|
||||
}
|
||||
function normalizeDownloader(value: unknown): Settings["downloader"] {
|
||||
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
if (normalized === "tidal" || normalized === "qobuz" || normalized === "amazon" || normalized === "auto") {
|
||||
return normalized;
|
||||
}
|
||||
return DEFAULT_SETTINGS.downloader;
|
||||
}
|
||||
function normalizeExistingFileCheckMode(mode: unknown): ExistingFileCheckMode {
|
||||
switch (typeof mode === "string" ? mode.trim().toLowerCase() : "") {
|
||||
case "isrc":
|
||||
case "upc":
|
||||
return "isrc";
|
||||
default:
|
||||
return "filename";
|
||||
}
|
||||
}
|
||||
function normalizeSettingsPayload(settings: SettingsPayload): SettingsPayload {
|
||||
const normalized: SettingsPayload = { ...settings };
|
||||
if ("darkMode" in normalized && !("themeMode" in normalized)) {
|
||||
normalized.themeMode = normalized.darkMode ? "dark" : "light";
|
||||
delete normalized.darkMode;
|
||||
}
|
||||
if (!("folderPreset" in normalized) &&
|
||||
("artistSubfolder" in normalized || "albumSubfolder" in normalized)) {
|
||||
const hasArtist = Boolean(normalized.artistSubfolder);
|
||||
const hasAlbum = Boolean(normalized.albumSubfolder);
|
||||
if (hasArtist && hasAlbum) {
|
||||
normalized.folderPreset = "artist-album";
|
||||
normalized.folderTemplate = "{artist}/{album}";
|
||||
}
|
||||
else if (hasArtist) {
|
||||
normalized.folderPreset = "artist";
|
||||
normalized.folderTemplate = "{artist}";
|
||||
}
|
||||
else if (hasAlbum) {
|
||||
normalized.folderPreset = "album";
|
||||
normalized.folderTemplate = "{album}";
|
||||
}
|
||||
else {
|
||||
normalized.folderPreset = "none";
|
||||
normalized.folderTemplate = "";
|
||||
}
|
||||
}
|
||||
if (!("filenamePreset" in normalized) && "filenameFormat" in normalized) {
|
||||
const format = normalized.filenameFormat;
|
||||
if (format === "title-artist") {
|
||||
normalized.filenamePreset = "artist-title";
|
||||
normalized.filenameTemplate = "{artist} - {title}";
|
||||
}
|
||||
else if (format === "artist-title") {
|
||||
normalized.filenamePreset = "artist-title";
|
||||
normalized.filenameTemplate = "{artist} - {title}";
|
||||
}
|
||||
else {
|
||||
normalized.filenamePreset = "title";
|
||||
normalized.filenameTemplate = "{title}";
|
||||
}
|
||||
}
|
||||
delete normalized.tidalVariant;
|
||||
if (!("tidalQuality" in normalized)) {
|
||||
normalized.tidalQuality = "LOSSLESS";
|
||||
}
|
||||
if (!("qobuzQuality" in normalized)) {
|
||||
normalized.qobuzQuality = "6";
|
||||
}
|
||||
if (!("amazonQuality" in normalized)) {
|
||||
normalized.amazonQuality = "16";
|
||||
}
|
||||
if (normalized.amazonQuality !== "16" && normalized.amazonQuality !== "24") {
|
||||
normalized.amazonQuality = "16";
|
||||
}
|
||||
if (!("autoOrder" in normalized)) {
|
||||
normalized.autoOrder = DEFAULT_SETTINGS.autoOrder;
|
||||
}
|
||||
if (!("autoQuality" in normalized)) {
|
||||
normalized.autoQuality = "16";
|
||||
}
|
||||
normalized.customTidalApi = normalizeCustomTidalApi(normalized.customTidalApi);
|
||||
normalized.customQobuzApi = normalizeCustomQobuzApi(normalized.customQobuzApi);
|
||||
normalized.downloader = normalizeDownloader(normalized.downloader);
|
||||
normalized.autoOrder = sanitizeAutoOrder(normalized.autoOrder);
|
||||
if (!("allowFallback" in normalized)) {
|
||||
normalized.allowFallback = true;
|
||||
}
|
||||
if (!("linkResolver" in normalized)) {
|
||||
normalized.linkResolver = "songlink";
|
||||
}
|
||||
if (!("allowResolverFallback" in normalized)) {
|
||||
normalized.allowResolverFallback = true;
|
||||
}
|
||||
if (!("createPlaylistFolder" in normalized)) {
|
||||
normalized.createPlaylistFolder = true;
|
||||
}
|
||||
if (!("playlistOwnerFolderName" in normalized)) {
|
||||
normalized.playlistOwnerFolderName = false;
|
||||
}
|
||||
if (!("createM3u8File" in normalized)) {
|
||||
normalized.createM3u8File = false;
|
||||
}
|
||||
normalized.previewVolume = normalizePreviewVolume(normalized.previewVolume);
|
||||
normalized.existingFileCheckMode = normalizeExistingFileCheckMode(normalized.existingFileCheckMode);
|
||||
if (!("useFirstArtistOnly" in normalized)) {
|
||||
normalized.useFirstArtistOnly = false;
|
||||
}
|
||||
if (!("useSingleGenre" in normalized)) {
|
||||
normalized.useSingleGenre = false;
|
||||
}
|
||||
if (!("embedGenre" in normalized)) {
|
||||
normalized.embedGenre = false;
|
||||
}
|
||||
if (!("separator" in normalized)) {
|
||||
normalized.separator = "semicolon";
|
||||
}
|
||||
if (!("redownloadWithSuffix" in normalized)) {
|
||||
normalized.redownloadWithSuffix = false;
|
||||
}
|
||||
normalized.operatingSystem = detectOS();
|
||||
const normalizedCustomFonts = normalizeCustomFonts(normalized.customFonts);
|
||||
normalized.customFonts = normalizedCustomFonts;
|
||||
normalized.fontFamily = normalizeFontFamily(normalized.fontFamily, normalizedCustomFonts);
|
||||
return normalized;
|
||||
}
|
||||
function toNormalizedSettings(settings: SettingsPayload): Settings {
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
...keepKnownSettings(normalizeSettingsPayload(settings)),
|
||||
} as Settings;
|
||||
}
|
||||
async function persistSettingsInternal(settings: Settings, notify = true): Promise<void> {
|
||||
cachedSettings = settings;
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
const settingsForBackend = { ...settings } as Record<string, unknown>;
|
||||
delete settingsForBackend.customFonts;
|
||||
await SaveToBackend(settingsForBackend);
|
||||
if (notify) {
|
||||
window.dispatchEvent(new CustomEvent("settingsUpdated", { detail: settings }));
|
||||
}
|
||||
}
|
||||
async function fetchDefaultPath(): Promise<string> {
|
||||
try {
|
||||
const data = await GetDefaults();
|
||||
@@ -167,90 +694,11 @@ async function fetchDefaultPath(): Promise<string> {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
const SETTINGS_KEY = "spotiflac-settings";
|
||||
let cachedSettings: Settings | null = null;
|
||||
function getSettingsFromLocalStorage(): Settings {
|
||||
try {
|
||||
const stored = localStorage.getItem(SETTINGS_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if ('darkMode' in parsed && !('themeMode' in parsed)) {
|
||||
parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
|
||||
delete parsed.darkMode;
|
||||
}
|
||||
if (!('folderPreset' in parsed) && ('artistSubfolder' in parsed || 'albumSubfolder' in parsed)) {
|
||||
const hasArtist = parsed.artistSubfolder;
|
||||
const hasAlbum = parsed.albumSubfolder;
|
||||
if (hasArtist && hasAlbum) {
|
||||
parsed.folderPreset = "artist-album";
|
||||
parsed.folderTemplate = "{artist}/{album}";
|
||||
}
|
||||
else if (hasArtist) {
|
||||
parsed.folderPreset = "artist";
|
||||
parsed.folderTemplate = "{artist}";
|
||||
}
|
||||
else if (hasAlbum) {
|
||||
parsed.folderPreset = "album";
|
||||
parsed.folderTemplate = "{album}";
|
||||
}
|
||||
else {
|
||||
parsed.folderPreset = "none";
|
||||
parsed.folderTemplate = "";
|
||||
}
|
||||
}
|
||||
if (!('filenamePreset' in parsed) && 'filenameFormat' in parsed) {
|
||||
const format = parsed.filenameFormat;
|
||||
if (format === "title-artist") {
|
||||
parsed.filenamePreset = "artist-title";
|
||||
parsed.filenameTemplate = "{artist} - {title}";
|
||||
}
|
||||
else if (format === "artist-title") {
|
||||
parsed.filenamePreset = "artist-title";
|
||||
parsed.filenameTemplate = "{artist} - {title}";
|
||||
}
|
||||
else {
|
||||
parsed.filenamePreset = "title";
|
||||
parsed.filenameTemplate = "{title}";
|
||||
}
|
||||
}
|
||||
parsed.operatingSystem = detectOS();
|
||||
if (!('tidalQuality' in parsed)) {
|
||||
parsed.tidalQuality = "LOSSLESS";
|
||||
}
|
||||
if (!('tidalVariant' in parsed)) {
|
||||
parsed.tidalVariant = "tidal";
|
||||
}
|
||||
if (!('qobuzQuality' in parsed)) {
|
||||
parsed.qobuzQuality = "6";
|
||||
}
|
||||
if (!('amazonQuality' in parsed)) {
|
||||
parsed.amazonQuality = "original";
|
||||
}
|
||||
if (!('autoOrder' in parsed)) {
|
||||
parsed.autoOrder = "tidal-qobuz-amazon";
|
||||
}
|
||||
if (!('autoQuality' in parsed)) {
|
||||
parsed.autoQuality = "16";
|
||||
}
|
||||
if (!('allowFallback' in parsed)) {
|
||||
parsed.allowFallback = true;
|
||||
}
|
||||
if (!('linkResolver' in parsed)) {
|
||||
parsed.linkResolver = "songlink";
|
||||
}
|
||||
if (!('allowResolverFallback' in parsed)) {
|
||||
parsed.allowResolverFallback = true;
|
||||
}
|
||||
if (!('playlistOwnerFolderName' in parsed)) {
|
||||
parsed.playlistOwnerFolderName = false;
|
||||
}
|
||||
if (!('separator' in parsed)) {
|
||||
parsed.separator = "semicolon";
|
||||
}
|
||||
if (!('redownloadWithSuffix' in parsed)) {
|
||||
parsed.redownloadWithSuffix = false;
|
||||
}
|
||||
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||
return toNormalizedSettings(JSON.parse(stored) as SettingsPayload);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
@@ -259,108 +707,25 @@ function getSettingsFromLocalStorage(): Settings {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
export function getSettings(): Settings {
|
||||
if (cachedSettings)
|
||||
if (cachedSettings) {
|
||||
return cachedSettings;
|
||||
}
|
||||
return getSettingsFromLocalStorage();
|
||||
}
|
||||
export async function loadSettings(): Promise<Settings> {
|
||||
try {
|
||||
const backendSettings = await LoadSettings();
|
||||
if (backendSettings) {
|
||||
const parsed = backendSettings as any;
|
||||
if ('darkMode' in parsed && !('themeMode' in parsed)) {
|
||||
parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
|
||||
delete parsed.darkMode;
|
||||
const parsed = backendSettings as SettingsPayload;
|
||||
const customFonts = await loadStoredCustomFonts(parsed.customFonts);
|
||||
cachedSettings = toNormalizedSettings({
|
||||
...parsed,
|
||||
customFonts,
|
||||
});
|
||||
if ("customFonts" in parsed) {
|
||||
await persistSettingsInternal(cachedSettings, false);
|
||||
}
|
||||
if (!('folderPreset' in parsed) && ('artistSubfolder' in parsed || 'albumSubfolder' in parsed)) {
|
||||
const hasArtist = parsed.artistSubfolder;
|
||||
const hasAlbum = parsed.albumSubfolder;
|
||||
if (hasArtist && hasAlbum) {
|
||||
parsed.folderPreset = "artist-album";
|
||||
parsed.folderTemplate = "{artist}/{album}";
|
||||
}
|
||||
else if (hasArtist) {
|
||||
parsed.folderPreset = "artist";
|
||||
parsed.folderTemplate = "{artist}";
|
||||
}
|
||||
else if (hasAlbum) {
|
||||
parsed.folderPreset = "album";
|
||||
parsed.folderTemplate = "{album}";
|
||||
}
|
||||
else {
|
||||
parsed.folderPreset = "none";
|
||||
parsed.folderTemplate = "";
|
||||
}
|
||||
}
|
||||
if (!('filenamePreset' in parsed) && 'filenameFormat' in parsed) {
|
||||
const format = parsed.filenameFormat;
|
||||
if (format === "title-artist") {
|
||||
parsed.filenamePreset = "artist-title";
|
||||
parsed.filenameTemplate = "{artist} - {title}";
|
||||
}
|
||||
else if (format === "artist-title") {
|
||||
parsed.filenamePreset = "artist-title";
|
||||
parsed.filenameTemplate = "{artist} - {title}";
|
||||
}
|
||||
else {
|
||||
parsed.filenamePreset = "title";
|
||||
parsed.filenameTemplate = "{title}";
|
||||
}
|
||||
}
|
||||
parsed.operatingSystem = detectOS();
|
||||
if (!('tidalQuality' in parsed)) {
|
||||
parsed.tidalQuality = "LOSSLESS";
|
||||
}
|
||||
if (!('tidalVariant' in parsed)) {
|
||||
parsed.tidalVariant = "tidal";
|
||||
}
|
||||
if (!('qobuzQuality' in parsed)) {
|
||||
parsed.qobuzQuality = "6";
|
||||
}
|
||||
if (!('amazonQuality' in parsed)) {
|
||||
parsed.amazonQuality = "original";
|
||||
}
|
||||
if (!('autoOrder' in parsed)) {
|
||||
parsed.autoOrder = "tidal-qobuz-amazon";
|
||||
}
|
||||
if (!('autoQuality' in parsed)) {
|
||||
parsed.autoQuality = "16";
|
||||
}
|
||||
if (!('allowFallback' in parsed)) {
|
||||
parsed.allowFallback = true;
|
||||
}
|
||||
if (!('linkResolver' in parsed)) {
|
||||
parsed.linkResolver = "songlink";
|
||||
}
|
||||
if (!('allowResolverFallback' in parsed)) {
|
||||
parsed.allowResolverFallback = true;
|
||||
}
|
||||
if (!('createPlaylistFolder' in parsed)) {
|
||||
parsed.createPlaylistFolder = true;
|
||||
}
|
||||
if (!('playlistOwnerFolderName' in parsed)) {
|
||||
parsed.playlistOwnerFolderName = false;
|
||||
}
|
||||
if (!('createM3u8File' in parsed)) {
|
||||
parsed.createM3u8File = false;
|
||||
}
|
||||
if (!('useFirstArtistOnly' in parsed)) {
|
||||
parsed.useFirstArtistOnly = false;
|
||||
}
|
||||
if (!('useSingleGenre' in parsed)) {
|
||||
parsed.useSingleGenre = false;
|
||||
}
|
||||
if (!('embedGenre' in parsed)) {
|
||||
parsed.embedGenre = false;
|
||||
}
|
||||
if (!('separator' in parsed)) {
|
||||
parsed.separator = "semicolon";
|
||||
}
|
||||
if (!('redownloadWithSuffix' in parsed)) {
|
||||
parsed.redownloadWithSuffix = false;
|
||||
}
|
||||
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
|
||||
return cachedSettings!;
|
||||
return cachedSettings;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
@@ -368,12 +733,19 @@ export async function loadSettings(): Promise<Settings> {
|
||||
}
|
||||
const local = getSettingsFromLocalStorage();
|
||||
try {
|
||||
await SaveToBackend(local as any);
|
||||
cachedSettings = local;
|
||||
const customFonts = await loadStoredCustomFonts(local.customFonts);
|
||||
const localWithFonts = toNormalizedSettings({
|
||||
...local,
|
||||
customFonts,
|
||||
});
|
||||
await persistSettingsInternal(localWithFonts, false);
|
||||
cachedSettings = localWithFonts;
|
||||
return localWithFonts;
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to migrate settings to backend:", error);
|
||||
}
|
||||
cachedSettings = local;
|
||||
return local;
|
||||
}
|
||||
export interface TemplateData {
|
||||
@@ -389,8 +761,9 @@ export interface TemplateData {
|
||||
playlist?: string;
|
||||
}
|
||||
export function parseTemplate(template: string, data: TemplateData): string {
|
||||
if (!template)
|
||||
if (!template) {
|
||||
return "";
|
||||
}
|
||||
let result = template;
|
||||
result = result.replace(/\{title\}/g, data.title || "Unknown Title");
|
||||
result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist");
|
||||
@@ -414,10 +787,8 @@ export async function getSettingsWithDefaults(): Promise<Settings> {
|
||||
}
|
||||
export async function saveSettings(settings: Settings): Promise<void> {
|
||||
try {
|
||||
cachedSettings = settings;
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
await SaveToBackend(settings as any);
|
||||
window.dispatchEvent(new CustomEvent('settingsUpdated', { detail: settings }));
|
||||
const normalizedSettings = toNormalizedSettings(settings as SettingsPayload);
|
||||
await persistSettingsInternal(normalizedSettings);
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to save settings:", error);
|
||||
@@ -431,7 +802,12 @@ export async function updateSettings(partial: Partial<Settings>): Promise<Settin
|
||||
}
|
||||
export async function resetToDefaultSettings(): Promise<Settings> {
|
||||
const defaultPath = await fetchDefaultPath();
|
||||
const defaultSettings = { ...DEFAULT_SETTINGS, downloadPath: defaultPath };
|
||||
const customFonts = await loadCustomFonts();
|
||||
const defaultSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
downloadPath: defaultPath,
|
||||
customFonts,
|
||||
};
|
||||
await saveSettings(defaultSettings);
|
||||
return defaultSettings;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MotionConfig } from "motion/react";
|
||||
import "./index.css";
|
||||
import App from "./App.tsx";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
createRoot(document.getElementById("root")!).render(<StrictMode>
|
||||
<App />
|
||||
<Toaster position="bottom-left" duration={1000}/>
|
||||
<MotionConfig reducedMotion="user">
|
||||
<App />
|
||||
<Toaster position="bottom-left" duration={1000}/>
|
||||
</MotionConfig>
|
||||
</StrictMode>);
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface AlbumInfo {
|
||||
release_date: string;
|
||||
artists: string;
|
||||
images: string;
|
||||
is_explicit?: boolean;
|
||||
upc?: string;
|
||||
batch?: string;
|
||||
}
|
||||
@@ -93,6 +94,7 @@ export interface DiscographyAlbum {
|
||||
artists: string;
|
||||
images: string;
|
||||
external_urls: string;
|
||||
is_explicit?: boolean;
|
||||
}
|
||||
export interface ArtistDiscographyResponse {
|
||||
artist_info: ArtistInfo;
|
||||
@@ -120,7 +122,7 @@ export interface DownloadRequest {
|
||||
release_date?: string;
|
||||
cover_url?: string;
|
||||
tidal_api_url?: string;
|
||||
tidal_variant?: "tidal" | "alt";
|
||||
qobuz_api_url?: string;
|
||||
output_dir?: string;
|
||||
audio_format?: string;
|
||||
folder_name?: string;
|
||||
@@ -152,6 +154,7 @@ export interface DownloadResponse {
|
||||
file?: string;
|
||||
error?: string;
|
||||
already_exists?: boolean;
|
||||
cancelled?: boolean;
|
||||
item_id?: string;
|
||||
}
|
||||
export interface HealthResponse {
|
||||
|
||||
Vendored
-142
@@ -1,142 +0,0 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {backend} from '../models';
|
||||
import {main} from '../models';
|
||||
|
||||
export function AddFetchHistory(arg1:backend.FetchHistoryItem):Promise<void>;
|
||||
|
||||
export function AddToDownloadQueue(arg1:string,arg2:string,arg3:string,arg4:string):Promise<string>;
|
||||
|
||||
export function CancelAllQueuedItems():Promise<void>;
|
||||
|
||||
export function CheckAPIStatus(arg1:string,arg2:string):Promise<boolean>;
|
||||
|
||||
export function CheckFFmpegInstalled():Promise<boolean>;
|
||||
|
||||
export function CheckFilesExistence(arg1:string,arg2:string,arg3:Array<main.CheckFileExistenceRequest>):Promise<Array<main.CheckFileExistenceResult>>;
|
||||
|
||||
export function CheckTrackAvailability(arg1:string):Promise<string>;
|
||||
|
||||
export function ClearAllDownloads():Promise<void>;
|
||||
|
||||
export function ClearCompletedDownloads():Promise<void>;
|
||||
|
||||
export function ClearDownloadHistory():Promise<void>;
|
||||
|
||||
export function ClearFetchHistory():Promise<void>;
|
||||
|
||||
export function ClearFetchHistoryByType(arg1:string):Promise<void>;
|
||||
|
||||
export function ConvertAudio(arg1:main.ConvertAudioRequest):Promise<Array<backend.ConvertAudioResult>>;
|
||||
|
||||
export function CreateM3U8File(arg1:string,arg2:string,arg3:Array<string>):Promise<void>;
|
||||
|
||||
export function DecodeAudioForAnalysis(arg1:string):Promise<backend.AnalysisDecodeResponse>;
|
||||
|
||||
export function DeleteDownloadHistoryItem(arg1:string):Promise<void>;
|
||||
|
||||
export function DeleteFetchHistoryItem(arg1:string):Promise<void>;
|
||||
|
||||
export function DownloadAvatar(arg1:main.AvatarDownloadRequest):Promise<backend.AvatarDownloadResponse>;
|
||||
|
||||
export function DownloadCover(arg1:main.CoverDownloadRequest):Promise<backend.CoverDownloadResponse>;
|
||||
|
||||
export function DownloadFFmpeg():Promise<main.DownloadFFmpegResponse>;
|
||||
|
||||
export function DownloadGalleryImage(arg1:main.GalleryImageDownloadRequest):Promise<backend.GalleryImageDownloadResponse>;
|
||||
|
||||
export function DownloadHeader(arg1:main.HeaderDownloadRequest):Promise<backend.HeaderDownloadResponse>;
|
||||
|
||||
export function DownloadLyrics(arg1:main.LyricsDownloadRequest):Promise<backend.LyricsDownloadResponse>;
|
||||
|
||||
export function DownloadTrack(arg1:main.DownloadRequest):Promise<main.DownloadResponse>;
|
||||
|
||||
export function ExportFailedDownloads():Promise<string>;
|
||||
|
||||
export function GetBrewPath():Promise<string>;
|
||||
|
||||
export function GetConfigPath():Promise<string>;
|
||||
|
||||
export function GetCurrentIPInfo():Promise<string>;
|
||||
|
||||
export function GetDefaults():Promise<Record<string, string>>;
|
||||
|
||||
export function GetDownloadHistory():Promise<Array<backend.HistoryItem>>;
|
||||
|
||||
export function GetDownloadProgress():Promise<backend.ProgressInfo>;
|
||||
|
||||
export function GetDownloadQueue():Promise<backend.DownloadQueueInfo>;
|
||||
|
||||
export function GetFetchHistory():Promise<Array<backend.FetchHistoryItem>>;
|
||||
|
||||
export function GetFileSizes(arg1:Array<string>):Promise<Record<string, number>>;
|
||||
|
||||
export function GetFlacInfoBatch(arg1:Array<string>):Promise<Array<backend.FlacInfo>>;
|
||||
|
||||
export function GetPreviewURL(arg1:string):Promise<string>;
|
||||
|
||||
export function GetRecentFetches():Promise<string>;
|
||||
|
||||
export function GetSpotifyMetadata(arg1:main.SpotifyMetadataRequest):Promise<string>;
|
||||
|
||||
export function GetStreamingURLs(arg1:string,arg2:string):Promise<string>;
|
||||
|
||||
export function GetTrackISRC(arg1:string):Promise<string>;
|
||||
|
||||
export function InstallFFmpegWithBrew():Promise<main.InstallFFmpegWithBrewResponse>;
|
||||
|
||||
export function IsBrewFFmpegInstalled():Promise<boolean>;
|
||||
|
||||
export function IsFFmpegInstalled():Promise<boolean>;
|
||||
|
||||
export function IsFFprobeInstalled():Promise<boolean>;
|
||||
|
||||
export function ListAudioFilesInDir(arg1:string):Promise<Array<backend.FileInfo>>;
|
||||
|
||||
export function ListDirectoryFiles(arg1:string):Promise<Array<backend.FileInfo>>;
|
||||
|
||||
export function LoadSettings():Promise<Record<string, any>>;
|
||||
|
||||
export function MarkDownloadItemFailed(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function OpenConfigFolder():Promise<void>;
|
||||
|
||||
export function OpenFolder(arg1:string):Promise<void>;
|
||||
|
||||
export function PreviewRenameFiles(arg1:Array<string>,arg2:string):Promise<Array<backend.RenamePreview>>;
|
||||
|
||||
export function Quit():Promise<void>;
|
||||
|
||||
export function ReadFileAsBase64(arg1:string):Promise<string>;
|
||||
|
||||
export function ReadFileMetadata(arg1:string):Promise<backend.AudioMetadata>;
|
||||
|
||||
export function ReadImageAsBase64(arg1:string):Promise<string>;
|
||||
|
||||
export function ReadTextFile(arg1:string):Promise<string>;
|
||||
|
||||
export function RenameFileTo(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function RenameFilesByMetadata(arg1:Array<string>,arg2:string):Promise<Array<backend.RenameResult>>;
|
||||
|
||||
export function ResampleAudio(arg1:main.ResampleAudioRequest):Promise<Array<backend.ResampleResult>>;
|
||||
|
||||
export function SaveRecentFetches(arg1:string):Promise<void>;
|
||||
|
||||
export function SaveSettings(arg1:Record<string, any>):Promise<void>;
|
||||
|
||||
export function SaveSpectrumImage(arg1:string,arg2:string):Promise<string>;
|
||||
|
||||
export function SearchSpotify(arg1:main.SpotifySearchRequest):Promise<backend.SearchResponse>;
|
||||
|
||||
export function SearchSpotifyByType(arg1:main.SpotifySearchByTypeRequest):Promise<Array<backend.SearchResult>>;
|
||||
|
||||
export function SelectAudioFiles():Promise<Array<string>>;
|
||||
|
||||
export function SelectFile():Promise<string>;
|
||||
|
||||
export function SelectFolder(arg1:string):Promise<string>;
|
||||
|
||||
export function SelectImageVideo():Promise<Array<string>>;
|
||||
|
||||
export function SkipDownloadItem(arg1:string,arg2:string):Promise<void>;
|
||||
@@ -1,279 +0,0 @@
|
||||
// @ts-check
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export function AddFetchHistory(arg1) {
|
||||
return window['go']['main']['App']['AddFetchHistory'](arg1);
|
||||
}
|
||||
|
||||
export function AddToDownloadQueue(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['AddToDownloadQueue'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function CancelAllQueuedItems() {
|
||||
return window['go']['main']['App']['CancelAllQueuedItems']();
|
||||
}
|
||||
|
||||
export function CheckAPIStatus(arg1, arg2) {
|
||||
return window['go']['main']['App']['CheckAPIStatus'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function CheckFFmpegInstalled() {
|
||||
return window['go']['main']['App']['CheckFFmpegInstalled']();
|
||||
}
|
||||
|
||||
export function CheckFilesExistence(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['CheckFilesExistence'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function CheckTrackAvailability(arg1) {
|
||||
return window['go']['main']['App']['CheckTrackAvailability'](arg1);
|
||||
}
|
||||
|
||||
export function ClearAllDownloads() {
|
||||
return window['go']['main']['App']['ClearAllDownloads']();
|
||||
}
|
||||
|
||||
export function ClearCompletedDownloads() {
|
||||
return window['go']['main']['App']['ClearCompletedDownloads']();
|
||||
}
|
||||
|
||||
export function ClearDownloadHistory() {
|
||||
return window['go']['main']['App']['ClearDownloadHistory']();
|
||||
}
|
||||
|
||||
export function ClearFetchHistory() {
|
||||
return window['go']['main']['App']['ClearFetchHistory']();
|
||||
}
|
||||
|
||||
export function ClearFetchHistoryByType(arg1) {
|
||||
return window['go']['main']['App']['ClearFetchHistoryByType'](arg1);
|
||||
}
|
||||
|
||||
export function ConvertAudio(arg1) {
|
||||
return window['go']['main']['App']['ConvertAudio'](arg1);
|
||||
}
|
||||
|
||||
export function CreateM3U8File(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['CreateM3U8File'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DecodeAudioForAnalysis(arg1) {
|
||||
return window['go']['main']['App']['DecodeAudioForAnalysis'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteDownloadHistoryItem(arg1) {
|
||||
return window['go']['main']['App']['DeleteDownloadHistoryItem'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteFetchHistoryItem(arg1) {
|
||||
return window['go']['main']['App']['DeleteFetchHistoryItem'](arg1);
|
||||
}
|
||||
|
||||
export function DownloadAvatar(arg1) {
|
||||
return window['go']['main']['App']['DownloadAvatar'](arg1);
|
||||
}
|
||||
|
||||
export function DownloadCover(arg1) {
|
||||
return window['go']['main']['App']['DownloadCover'](arg1);
|
||||
}
|
||||
|
||||
export function DownloadFFmpeg() {
|
||||
return window['go']['main']['App']['DownloadFFmpeg']();
|
||||
}
|
||||
|
||||
export function DownloadGalleryImage(arg1) {
|
||||
return window['go']['main']['App']['DownloadGalleryImage'](arg1);
|
||||
}
|
||||
|
||||
export function DownloadHeader(arg1) {
|
||||
return window['go']['main']['App']['DownloadHeader'](arg1);
|
||||
}
|
||||
|
||||
export function DownloadLyrics(arg1) {
|
||||
return window['go']['main']['App']['DownloadLyrics'](arg1);
|
||||
}
|
||||
|
||||
export function DownloadTrack(arg1) {
|
||||
return window['go']['main']['App']['DownloadTrack'](arg1);
|
||||
}
|
||||
|
||||
export function ExportFailedDownloads() {
|
||||
return window['go']['main']['App']['ExportFailedDownloads']();
|
||||
}
|
||||
|
||||
export function GetBrewPath() {
|
||||
return window['go']['main']['App']['GetBrewPath']();
|
||||
}
|
||||
|
||||
export function GetConfigPath() {
|
||||
return window['go']['main']['App']['GetConfigPath']();
|
||||
}
|
||||
|
||||
export function GetCurrentIPInfo() {
|
||||
return window['go']['main']['App']['GetCurrentIPInfo']();
|
||||
}
|
||||
|
||||
export function GetDefaults() {
|
||||
return window['go']['main']['App']['GetDefaults']();
|
||||
}
|
||||
|
||||
export function GetDownloadHistory() {
|
||||
return window['go']['main']['App']['GetDownloadHistory']();
|
||||
}
|
||||
|
||||
export function GetDownloadProgress() {
|
||||
return window['go']['main']['App']['GetDownloadProgress']();
|
||||
}
|
||||
|
||||
export function GetDownloadQueue() {
|
||||
return window['go']['main']['App']['GetDownloadQueue']();
|
||||
}
|
||||
|
||||
export function GetFetchHistory() {
|
||||
return window['go']['main']['App']['GetFetchHistory']();
|
||||
}
|
||||
|
||||
export function GetFileSizes(arg1) {
|
||||
return window['go']['main']['App']['GetFileSizes'](arg1);
|
||||
}
|
||||
|
||||
export function GetFlacInfoBatch(arg1) {
|
||||
return window['go']['main']['App']['GetFlacInfoBatch'](arg1);
|
||||
}
|
||||
|
||||
export function GetPreviewURL(arg1) {
|
||||
return window['go']['main']['App']['GetPreviewURL'](arg1);
|
||||
}
|
||||
|
||||
export function GetRecentFetches() {
|
||||
return window['go']['main']['App']['GetRecentFetches']();
|
||||
}
|
||||
|
||||
export function GetSpotifyMetadata(arg1) {
|
||||
return window['go']['main']['App']['GetSpotifyMetadata'](arg1);
|
||||
}
|
||||
|
||||
export function GetStreamingURLs(arg1, arg2) {
|
||||
return window['go']['main']['App']['GetStreamingURLs'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function GetTrackISRC(arg1) {
|
||||
return window['go']['main']['App']['GetTrackISRC'](arg1);
|
||||
}
|
||||
|
||||
export function InstallFFmpegWithBrew() {
|
||||
return window['go']['main']['App']['InstallFFmpegWithBrew']();
|
||||
}
|
||||
|
||||
export function IsBrewFFmpegInstalled() {
|
||||
return window['go']['main']['App']['IsBrewFFmpegInstalled']();
|
||||
}
|
||||
|
||||
export function IsFFmpegInstalled() {
|
||||
return window['go']['main']['App']['IsFFmpegInstalled']();
|
||||
}
|
||||
|
||||
export function IsFFprobeInstalled() {
|
||||
return window['go']['main']['App']['IsFFprobeInstalled']();
|
||||
}
|
||||
|
||||
export function ListAudioFilesInDir(arg1) {
|
||||
return window['go']['main']['App']['ListAudioFilesInDir'](arg1);
|
||||
}
|
||||
|
||||
export function ListDirectoryFiles(arg1) {
|
||||
return window['go']['main']['App']['ListDirectoryFiles'](arg1);
|
||||
}
|
||||
|
||||
export function LoadSettings() {
|
||||
return window['go']['main']['App']['LoadSettings']();
|
||||
}
|
||||
|
||||
export function MarkDownloadItemFailed(arg1, arg2) {
|
||||
return window['go']['main']['App']['MarkDownloadItemFailed'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function OpenConfigFolder() {
|
||||
return window['go']['main']['App']['OpenConfigFolder']();
|
||||
}
|
||||
|
||||
export function OpenFolder(arg1) {
|
||||
return window['go']['main']['App']['OpenFolder'](arg1);
|
||||
}
|
||||
|
||||
export function PreviewRenameFiles(arg1, arg2) {
|
||||
return window['go']['main']['App']['PreviewRenameFiles'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function Quit() {
|
||||
return window['go']['main']['App']['Quit']();
|
||||
}
|
||||
|
||||
export function ReadFileAsBase64(arg1) {
|
||||
return window['go']['main']['App']['ReadFileAsBase64'](arg1);
|
||||
}
|
||||
|
||||
export function ReadFileMetadata(arg1) {
|
||||
return window['go']['main']['App']['ReadFileMetadata'](arg1);
|
||||
}
|
||||
|
||||
export function ReadImageAsBase64(arg1) {
|
||||
return window['go']['main']['App']['ReadImageAsBase64'](arg1);
|
||||
}
|
||||
|
||||
export function ReadTextFile(arg1) {
|
||||
return window['go']['main']['App']['ReadTextFile'](arg1);
|
||||
}
|
||||
|
||||
export function RenameFileTo(arg1, arg2) {
|
||||
return window['go']['main']['App']['RenameFileTo'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function RenameFilesByMetadata(arg1, arg2) {
|
||||
return window['go']['main']['App']['RenameFilesByMetadata'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ResampleAudio(arg1) {
|
||||
return window['go']['main']['App']['ResampleAudio'](arg1);
|
||||
}
|
||||
|
||||
export function SaveRecentFetches(arg1) {
|
||||
return window['go']['main']['App']['SaveRecentFetches'](arg1);
|
||||
}
|
||||
|
||||
export function SaveSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveSettings'](arg1);
|
||||
}
|
||||
|
||||
export function SaveSpectrumImage(arg1, arg2) {
|
||||
return window['go']['main']['App']['SaveSpectrumImage'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SearchSpotify(arg1) {
|
||||
return window['go']['main']['App']['SearchSpotify'](arg1);
|
||||
}
|
||||
|
||||
export function SearchSpotifyByType(arg1) {
|
||||
return window['go']['main']['App']['SearchSpotifyByType'](arg1);
|
||||
}
|
||||
|
||||
export function SelectAudioFiles() {
|
||||
return window['go']['main']['App']['SelectAudioFiles']();
|
||||
}
|
||||
|
||||
export function SelectFile() {
|
||||
return window['go']['main']['App']['SelectFile']();
|
||||
}
|
||||
|
||||
export function SelectFolder(arg1) {
|
||||
return window['go']['main']['App']['SelectFolder'](arg1);
|
||||
}
|
||||
|
||||
export function SelectImageVideo() {
|
||||
return window['go']['main']['App']['SelectImageVideo']();
|
||||
}
|
||||
|
||||
export function SkipDownloadItem(arg1, arg2) {
|
||||
return window['go']['main']['App']['SkipDownloadItem'](arg1, arg2);
|
||||
}
|
||||
@@ -3,19 +3,21 @@ module github.com/afkarxyz/SpotiFLAC
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/Eyevinn/mp4ff v0.52.0
|
||||
github.com/bogem/id3v2/v2 v2.1.4
|
||||
github.com/go-flac/flacpicture v0.3.0
|
||||
github.com/go-flac/flacvorbis v0.2.0
|
||||
github.com/go-flac/go-flac v1.0.0
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/ulikunitz/xz v0.5.15
|
||||
github.com/wailsapp/wails/v2 v2.11.0
|
||||
github.com/wailsapp/wails/v2 v2.12.0
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
golang.org/x/image v0.12.0
|
||||
golang.org/x/text v0.31.0
|
||||
)
|
||||
|
||||
require (
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
|
||||
@@ -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/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
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-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
|
||||
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8=
|
||||
github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -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/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
||||
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
|
||||
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"info": {
|
||||
"productName": "SpotiFLAC",
|
||||
"productVersion": "7.1.5",
|
||||
"productVersion": "7.1.8",
|
||||
"copyright": "© 2026 afkarxyz"
|
||||
},
|
||||
"wailsjsdir": "./frontend",
|
||||
|
||||
Reference in New Issue
Block a user