diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c114282..ecaf71a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -55,7 +55,7 @@ body: - type: input id: version attributes: - label: SpotiDownloader Version + label: SpotiFLAC Version placeholder: e.g. v7.1.0 validations: required: true diff --git a/README.md b/README.md index 458b384..665777e 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account ### [SpotiFLAC Next](https://github.com/afkarxyz/SpotiFLAC-Next) -Get Spotify tracks in true FLAC from Tidal, Qobuz, Amazon Music & Deezer — no account required. +Get Spotify tracks in true Lossless from Tidal, Qobuz, Amazon Music, Deezer & Apple Music — no account required. ### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader) @@ -112,7 +112,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) · [Song.link](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) · [dabmusic.xyz](https://dabmusic.xyz) > [!TIP] > diff --git a/app.go b/app.go index 4a8f023..05c24d7 100644 --- a/app.go +++ b/app.go @@ -4,8 +4,10 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "io" + "net/url" "os" "path/filepath" @@ -23,10 +25,73 @@ type App struct { ctx context.Context } +const checkOperationTimeout = 10 * time.Second + func NewApp() *App { return &App{} } +type timedResult[T any] struct { + value T + err error +} + +func runWithTimeout[T any](timeout time.Duration, fn func() (T, error)) (T, error) { + resultCh := make(chan timedResult[T], 1) + + go func() { + value, err := fn() + resultCh <- timedResult[T]{value: value, err: err} + }() + + select { + case result := <-resultCh: + return result.value, result.err + case <-time.After(timeout): + var zero T + return zero, fmt.Errorf("operation timed out after %s", timeout) + } +} + +func containsStreamingURL(body []byte) bool { + trimmedBody := strings.TrimSpace(string(body)) + if trimmedBody == "" { + return false + } + + var directResp struct { + URL string `json:"url"` + } + if err := json.Unmarshal(body, &directResp); err == nil && isStreamingURL(directResp.URL) { + return true + } + + var nestedResp struct { + Data struct { + URL string `json:"url"` + } `json:"data"` + } + if err := json.Unmarshal(body, &nestedResp); err == nil && isStreamingURL(nestedResp.Data.URL) { + return true + } + + return isStreamingURL(trimmedBody) +} + +func isStreamingURL(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 (a *App) getFirstArtist(artistString string) string { if artistString == "" { return "" @@ -46,10 +111,18 @@ func (a *App) startup(ctx context.Context) { if err := backend.InitHistoryDB("SpotiFLAC"); err != nil { fmt.Printf("Failed to init history DB: %v\n", err) } + if err := backend.InitISRCCacheDB(); err != nil { + fmt.Printf("Failed to init ISRC cache DB: %v\n", err) + } + if err := backend.InitProviderPriorityDB(); err != nil { + fmt.Printf("Failed to init provider priority DB: %v\n", err) + } } func (a *App) shutdown(ctx context.Context) { backend.CloseHistoryDB() + backend.CloseISRCCacheDB() + backend.CloseProviderPriorityDB() } type SpotifyMetadataRequest struct { @@ -408,7 +481,10 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { if req.Service == "qobuz" { go func() { client := backend.NewSongLinkClient() - isrc, _ := client.GetISRCDirect(req.SpotifyID) + isrc, err := client.GetISRCDirect(req.SpotifyID) + if err != nil { + fmt.Printf("Warning: failed to resolve ISRC for Qobuz: %v\n", err) + } isrcChan <- isrc }() } else { @@ -455,7 +531,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { if quality == "" { quality = "6" } - filename, err = downloader.DownloadTrackWithISRC(isrc, req.SpotifyID, req.OutputDir, quality, 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, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) + filename, err = downloader.DownloadTrackWithISRC(isrc, req.OutputDir, quality, 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, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) default: return DownloadResponse{ @@ -500,7 +576,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { Success: false, Error: errorMessage, ItemID: itemID, - }, fmt.Errorf(errorMessage) + }, errors.New(errorMessage) } if !validated { fmt.Printf("[DownloadValidation] Skipped duration validation for %s (expected=%ds)\n", filename, req.Duration) @@ -626,12 +702,8 @@ func (a *App) OpenFolder(path string) error { } func (a *App) OpenConfigFolder() error { - homeDir, err := os.UserHomeDir() + configDir, err := backend.EnsureAppDir() if err != nil { - return fmt.Errorf("failed to get home directory: %v", err) - } - configDir := filepath.Join(homeDir, ".spotiflac") - if err := os.MkdirAll(configDir, 0755); err != nil { return fmt.Errorf("failed to create config directory: %v", err) } return backend.OpenFolderInExplorer(configDir) @@ -750,49 +822,65 @@ func (a *App) ExportFailedDownloads() (string, error) { } func (a *App) CheckAPIStatus(apiType string, apiURL string) bool { - var checkURL string - if apiType == "tidal" { - checkURL = fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL) - } else if apiType == "qobuz" { - checkURL = fmt.Sprintf("%s/api/stream?trackId=360735657&format_id=27", apiURL) - } else if apiType == "qbz" { - checkURL = fmt.Sprintf("%s/api/track/360735657?quality=27", apiURL) - } else if apiType == "amazon" { - checkURL = fmt.Sprintf("%s/status", apiURL) - } else { - checkURL = apiURL - } + isOnline, err := runWithTimeout(checkOperationTimeout, func() (bool, error) { + var checkURL string + if apiType == "tidal" { + checkURL = fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL) + } else if apiType == "qobuz" { + checkURL = fmt.Sprintf("%s/api/stream?trackId=360735657&quality=27", apiURL) + } else if apiType == "qbz" { + checkURL = fmt.Sprintf("%s/api/track/360735657?quality=27", apiURL) + } else if apiType == "amazon" { + checkURL = fmt.Sprintf("%s/status", apiURL) + } else { + checkURL = apiURL + } - client := &http.Client{Timeout: 15 * time.Second} - req, err := http.NewRequest("GET", checkURL, nil) - if err != nil { - return false - } - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest("GET", checkURL, nil) + if err != nil { + return false, err + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") - maxRetries := 3 - for i := 0; i < maxRetries; i++ { - resp, err := client.Do(req) - if err == nil { - statusCode := resp.StatusCode - if apiType == "amazon" && statusCode == 200 { + maxRetries := 3 + for i := 0; i < maxRetries; i++ { + resp, err := client.Do(req) + if err == nil { + statusCode := resp.StatusCode body, readErr := io.ReadAll(resp.Body) resp.Body.Close() - if readErr == nil && strings.Contains(string(body), `"amazonMusic":"up"`) { - return true + if readErr != nil { + if i < maxRetries-1 { + time.Sleep(1 * time.Second) + } + continue } - } else { - resp.Body.Close() - if statusCode == 200 { - return true + + if apiType == "amazon" && statusCode == 200 && strings.Contains(string(body), `"amazonMusic":"up"`) { + return true, nil + } + + if (apiType == "qobuz" || apiType == "qbz") && statusCode == 200 && containsStreamingURL(body) { + return true, nil + } + + if apiType != "amazon" && apiType != "qobuz" && apiType != "qbz" && statusCode == 200 { + return true, nil } } + if i < maxRetries-1 { + time.Sleep(1 * time.Second) + } } - if i < maxRetries-1 { - time.Sleep(1 * time.Second) - } + return false, nil + }) + if err != nil { + fmt.Printf("CheckAPIStatus timeout/error for %s (%s): %v\n", apiType, apiURL, err) + return false } - return false + + return isOnline } func (a *App) Quit() { @@ -1078,18 +1166,20 @@ func (a *App) CheckTrackAvailability(spotifyTrackID string) (string, error) { return "", fmt.Errorf("spotify track ID is required") } - client := backend.NewSongLinkClient() - availability, err := client.CheckTrackAvailability(spotifyTrackID) - if err != nil { - return "", err - } + return runWithTimeout(checkOperationTimeout, func() (string, error) { + client := backend.NewSongLinkClient() + availability, err := client.CheckTrackAvailability(spotifyTrackID) + if err != nil { + return "", err + } - jsonData, err := json.Marshal(availability) - if err != nil { - return "", fmt.Errorf("failed to encode response: %v", err) - } + jsonData, err := json.Marshal(availability) + if err != nil { + return "", fmt.Errorf("failed to encode response: %v", err) + } - return string(jsonData), nil + return string(jsonData), nil + }) } func (a *App) IsFFmpegInstalled() (bool, error) { @@ -1257,6 +1347,14 @@ func (a *App) ReadFileAsBase64(filePath string) (string, error) { return base64.StdEncoding.EncodeToString(content), nil } +func (a *App) DecodeAudioForAnalysis(filePath string) (*backend.AnalysisDecodeResponse, error) { + if filePath == "" { + return nil, fmt.Errorf("file path is required") + } + + return backend.DecodeAudioForAnalysis(filePath) +} + func (a *App) RenameFileTo(oldPath, newName string) error { dir := filepath.Dir(oldPath) ext := filepath.Ext(oldPath) diff --git a/backend/amazon.go b/backend/amazon.go index 47d9754..a3484c5 100644 --- a/backend/amazon.go +++ b/backend/amazon.go @@ -387,6 +387,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename DiscNumber: spotifyDiscNumber, TotalDiscs: spotifyTotalDiscs, URL: spotifyURL, + Comment: spotifyURL, Copyright: spotifyCopyright, Publisher: spotifyPublisher, Description: "https://github.com/afkarxyz/SpotiFLAC", diff --git a/backend/analysis.go b/backend/analysis.go index daf0f0e..2753899 100644 --- a/backend/analysis.go +++ b/backend/analysis.go @@ -1,6 +1,8 @@ package backend import ( + "bytes" + "encoding/base64" "fmt" "os" "os/exec" @@ -24,6 +26,16 @@ type AnalysisResult struct { RMSLevel float64 `json:"rms_level"` } +type AnalysisDecodeResponse struct { + PCMBase64 string `json:"pcm_base64"` + SampleRate uint32 `json:"sample_rate"` + Channels uint8 `json:"channels"` + BitsPerSample uint8 `json:"bits_per_sample"` + Duration float64 `json:"duration"` + BitrateKbps int `json:"bitrate_kbps,omitempty"` + BitDepth string `json:"bit_depth,omitempty"` +} + func GetTrackMetadata(filepath string) (*AnalysisResult, error) { if !fileExists(filepath) { return nil, fmt.Errorf("file does not exist: %s", filepath) @@ -113,3 +125,90 @@ func GetMetadataWithFFprobe(filePath string) (*AnalysisResult, error) { return res, nil } + +func DecodeAudioForAnalysis(filePath string) (*AnalysisDecodeResponse, error) { + metadata, err := GetTrackMetadata(filePath) + if err != nil { + return nil, err + } + + pcmBase64, err := extractAnalysisPCMBase64(filePath) + if err != nil { + return nil, err + } + + resp := &AnalysisDecodeResponse{ + PCMBase64: pcmBase64, + SampleRate: metadata.SampleRate, + Channels: metadata.Channels, + BitsPerSample: metadata.BitsPerSample, + Duration: metadata.Duration, + BitDepth: metadata.BitDepth, + } + + if metadata.Bitrate > 0 { + resp.BitrateKbps = metadata.Bitrate / 1000 + } + + return resp, nil +} + +func extractAnalysisPCMBase64(filePath string) (string, error) { + ffmpegPath, err := GetFFmpegPath() + if err != nil { + return "", err + } + + argSets := [][]string{ + { + "-v", "error", + "-i", filePath, + "-vn", + "-map", "0:a:0", + "-af", "pan=mono|c0=c0", + "-f", "s16le", + "-acodec", "pcm_s16le", + "pipe:1", + }, + { + "-v", "error", + "-i", filePath, + "-vn", + "-map", "0:a:0", + "-ac", "1", + "-f", "s16le", + "-acodec", "pcm_s16le", + "pipe:1", + }, + } + + var lastErr error + + for _, args := range argSets { + var stdout bytes.Buffer + var stderr bytes.Buffer + + cmd := exec.Command(ffmpegPath, args...) + setHideWindow(cmd) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + lastErr = fmt.Errorf("ffmpeg analysis decode failed: %w - %s", err, strings.TrimSpace(stderr.String())) + continue + } + + if stdout.Len() == 0 { + lastErr = fmt.Errorf("ffmpeg analysis decode returned empty PCM output") + continue + } + + return base64.StdEncoding.EncodeToString(stdout.Bytes()), nil + } + + if lastErr != nil { + return "", lastErr + } + + return "", fmt.Errorf("ffmpeg analysis decode failed") +} diff --git a/backend/config.go b/backend/config.go index 51333d1..5f1b562 100644 --- a/backend/config.go +++ b/backend/config.go @@ -1,8 +1,10 @@ package backend import ( + "encoding/json" "os" "path/filepath" + "strings" ) func GetDefaultMusicPath() string { @@ -15,3 +17,87 @@ func GetDefaultMusicPath() string { return filepath.Join(homeDir, "Music") } + +func GetConfigPath() (string, error) { + dir, err := EnsureAppDir() + if err != nil { + return "", err + } + + return filepath.Join(dir, "config.json"), nil +} + +func LoadConfigSettings() (map[string]interface{}, error) { + configPath, err := GetConfigPath() + if err != nil { + return nil, err + } + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return nil, nil + } + + data, err := os.ReadFile(configPath) + if err != nil { + return nil, err + } + + var settings map[string]interface{} + if err := json.Unmarshal(data, &settings); err != nil { + return nil, err + } + + return settings, nil +} + +func GetSpotFetchAPISettings() (bool, string) { + settings, err := LoadConfigSettings() + if err != nil || settings == nil { + return false, "" + } + + useAPI, _ := settings["useSpotFetchAPI"].(bool) + if !useAPI { + return false, "" + } + + apiURL, _ := settings["spotFetchAPIUrl"].(string) + if apiURL == "" { + apiURL = "https://sp.afkarxyz.qzz.io/api" + } + + return true, apiURL +} + +func GetLinkResolverSetting() string { + settings, err := LoadConfigSettings() + if err != nil || settings == nil { + return linkResolverProviderDeezerSongLink + } + + resolver, _ := settings["linkResolver"].(string) + switch strings.TrimSpace(strings.ToLower(resolver)) { + case "songlink", linkResolverProviderDeezerSongLink: + return linkResolverProviderDeezerSongLink + case "songstats": + return linkResolverProviderSongstats + case "": + return linkResolverProviderDeezerSongLink + default: + return linkResolverProviderDeezerSongLink + } +} + +func GetLinkResolverAllowFallback() bool { + settings, err := LoadConfigSettings() + if err != nil || settings == nil { + return true + } + + allowFallback, ok := settings["allowResolverFallback"].(bool) + if !ok { + return true + } + + return allowFallback +} diff --git a/backend/ffmpeg.go b/backend/ffmpeg.go index 5457c93..b9cd3e3 100644 --- a/backend/ffmpeg.go +++ b/backend/ffmpeg.go @@ -58,7 +58,7 @@ func ValidateExecutable(path string) error { return nil } -func GetFFmpegDir() (string, error) { +func GetAppDir() (string, error) { homeDir, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("failed to get home directory: %w", err) @@ -66,6 +66,23 @@ func GetFFmpegDir() (string, error) { return filepath.Join(homeDir, ".spotiflac"), nil } +func EnsureAppDir() (string, error) { + appDir, err := GetAppDir() + if err != nil { + return "", err + } + + if err := os.MkdirAll(appDir, 0o755); err != nil { + return "", fmt.Errorf("failed to create app directory: %w", err) + } + + return appDir, nil +} + +func GetFFmpegDir() (string, error) { + return EnsureAppDir() +} + func GetFFmpegPath() (string, error) { ffmpegDir, err := GetFFmpegDir() if err != nil { diff --git a/backend/file_dialog.go b/backend/file_dialog.go index b47e1de..a2f740f 100644 --- a/backend/file_dialog.go +++ b/backend/file_dialog.go @@ -11,8 +11,8 @@ func SelectMultipleFiles(ctx context.Context) ([]string, error) { Title: "Select Audio Files", Filters: []runtime.FileFilter{ { - DisplayName: "Audio Files (*.mp3, *.m4a, *.flac)", - Pattern: "*.mp3;*.m4a;*.flac", + DisplayName: "Audio Files (*.mp3, *.m4a, *.flac, *.aac)", + Pattern: "*.mp3;*.m4a;*.flac;*.aac", }, { DisplayName: "MP3 Files (*.mp3)", @@ -26,6 +26,10 @@ func SelectMultipleFiles(ctx context.Context) ([]string, error) { DisplayName: "FLAC Files (*.flac)", Pattern: "*.flac", }, + { + DisplayName: "AAC Files (*.aac)", + Pattern: "*.aac", + }, { DisplayName: "All Files (*.*)", Pattern: "*.*", diff --git a/backend/filemanager.go b/backend/filemanager.go index dda8dc3..9b915fb 100644 --- a/backend/filemanager.go +++ b/backend/filemanager.go @@ -94,7 +94,7 @@ func ListAudioFiles(dirPath string) ([]FileInfo, error) { } ext := strings.ToLower(filepath.Ext(path)) - if ext == ".flac" || ext == ".mp3" || ext == ".m4a" { + if ext == ".flac" || ext == ".mp3" || ext == ".m4a" || ext == ".aac" { result = append(result, FileInfo{ Name: info.Name(), Path: path, diff --git a/backend/filename.go b/backend/filename.go index 1e6b3d4..0c8a373 100644 --- a/backend/filename.go +++ b/backend/filename.go @@ -1,9 +1,7 @@ package backend import ( - "encoding/json" "fmt" - "os" "path/filepath" "regexp" "strings" @@ -139,25 +137,17 @@ func NormalizePath(folderPath string) string { } func GetSeparator() string { - dir, err := GetFFmpegDir() - if err != nil { - return "; " - } - configPath := filepath.Join(dir, "config.json") - data, err := os.ReadFile(configPath) - if err != nil { + settings, err := LoadConfigSettings() + if err != nil || settings == nil { return "; " } - var settings map[string]interface{} - if err := json.Unmarshal(data, &settings); err == nil { - if sep, ok := settings["separator"].(string); ok { - if sep == "comma" { - return ", " - } - if sep == "semicolon" { - return "; " - } + if sep, ok := settings["separator"].(string); ok { + if sep == "comma" { + return ", " + } + if sep == "semicolon" { + return "; " } } return "; " diff --git a/backend/history.go b/backend/history.go index 754db43..18804c0 100644 --- a/backend/history.go +++ b/backend/history.go @@ -3,7 +3,6 @@ package backend import ( "encoding/json" "fmt" - "os" "path/filepath" "sort" "time" @@ -35,13 +34,10 @@ const ( func InitHistoryDB(appName string) error { - appDir, err := GetFFmpegDir() + appDir, err := EnsureAppDir() if err != nil { return err } - if _, err := os.Stat(appDir); os.IsNotExist(err) { - os.MkdirAll(appDir, 0755) - } dbPath := filepath.Join(appDir, "history.db") db, err := bolt.Open(dbPath, 0600, &bolt.Options{Timeout: 1 * time.Second}) diff --git a/backend/isrc_cache.go b/backend/isrc_cache.go new file mode 100644 index 0000000..cbe6b34 --- /dev/null +++ b/backend/isrc_cache.go @@ -0,0 +1,137 @@ +package backend + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" + "sync" + "time" + + bolt "go.etcd.io/bbolt" +) + +const ( + isrcCacheDBFile = "isrc_cache.db" + isrcCacheBucket = "SpotifyTrackISRC" +) + +type isrcCacheEntry struct { + TrackID string `json:"track_id"` + ISRC string `json:"isrc"` + UpdatedAt int64 `json:"updated_at"` +} + +var ( + isrcCacheDB *bolt.DB + isrcCacheDBMu sync.Mutex +) + +func InitISRCCacheDB() error { + isrcCacheDBMu.Lock() + defer isrcCacheDBMu.Unlock() + + if isrcCacheDB != nil { + return nil + } + + appDir, err := EnsureAppDir() + if err != nil { + return err + } + + dbPath := filepath.Join(appDir, isrcCacheDBFile) + db, err := bolt.Open(dbPath, 0o600, &bolt.Options{Timeout: 1 * time.Second}) + if err != nil { + return err + } + + if err := db.Update(func(tx *bolt.Tx) error { + _, err := tx.CreateBucketIfNotExists([]byte(isrcCacheBucket)) + return err + }); err != nil { + db.Close() + return err + } + + isrcCacheDB = db + return nil +} + +func CloseISRCCacheDB() { + isrcCacheDBMu.Lock() + defer isrcCacheDBMu.Unlock() + + if isrcCacheDB != nil { + _ = isrcCacheDB.Close() + isrcCacheDB = nil + } +} + +func GetCachedISRC(trackID string) (string, error) { + normalizedTrackID := strings.TrimSpace(trackID) + if normalizedTrackID == "" { + return "", nil + } + + if err := InitISRCCacheDB(); err != nil { + return "", err + } + + var cachedISRC string + err := isrcCacheDB.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(isrcCacheBucket)) + if bucket == nil { + return nil + } + + value := bucket.Get([]byte(normalizedTrackID)) + if len(value) == 0 { + return nil + } + + var entry isrcCacheEntry + if err := json.Unmarshal(value, &entry); err != nil { + return err + } + + cachedISRC = strings.ToUpper(strings.TrimSpace(entry.ISRC)) + return nil + }) + if err != nil { + return "", err + } + + return cachedISRC, nil +} + +func PutCachedISRC(trackID string, isrc string) error { + normalizedTrackID := strings.TrimSpace(trackID) + normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc)) + if normalizedTrackID == "" || normalizedISRC == "" { + return nil + } + + if err := InitISRCCacheDB(); err != nil { + return err + } + + entry := isrcCacheEntry{ + TrackID: normalizedTrackID, + ISRC: normalizedISRC, + UpdatedAt: time.Now().Unix(), + } + + payload, err := json.Marshal(entry) + if err != nil { + return fmt.Errorf("failed to encode ISRC cache entry: %w", err) + } + + return isrcCacheDB.Update(func(tx *bolt.Tx) error { + bucket, err := tx.CreateBucketIfNotExists([]byte(isrcCacheBucket)) + if err != nil { + return err + } + return bucket.Put([]byte(normalizedTrackID), payload) + }) +} diff --git a/backend/isrc_finder.go b/backend/isrc_finder.go new file mode 100644 index 0000000..cf077ea --- /dev/null +++ b/backend/isrc_finder.go @@ -0,0 +1,572 @@ +package backend + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +const ( + spotifyServerTimeURL = "https://open.spotify.com/api/server-time" + spotifySessionTokenURL = "https://open.spotify.com/api/token" + spotifyTOTPSecretsURL = "https://git.gay/thereallo/totp-secrets/raw/branch/main/secrets/secretDict.json" + spotifyGIDMetadataURL = "https://spclient.wg.spotify.com/metadata/4/%s/%s?market=from_token" + spotifyTOTPPeriod = 30 + spotifyTOTPDigits = 6 + spotifyBase62Alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + spotifyTokenCacheFile = ".isrc-finder-token.json" + spotifySecretsCacheFile = "spotify-secret-dict-cache.json" + spotifySecretsCacheTTL = 24 * time.Hour +) + +var spotifyAnonymousTokenMu sync.Mutex + +type spotifyAnonymousToken struct { + AccessToken string `json:"accessToken"` + AccessTokenExpirationTimestampMs int64 `json:"accessTokenExpirationTimestampMs"` +} + +type spotifyServerTimeResponse struct { + ServerTime int64 `json:"serverTime"` +} + +type spotifySecretsCache struct { + FetchedAtUnix int64 `json:"fetched_at_unix"` + Secrets map[string][]int `json:"secrets"` +} + +type spotifyTrackRawData struct { + ExternalID []struct { + Type string `json:"type"` + ID string `json:"id"` + } `json:"external_id"` +} + +type spotFetchISRCResponse struct { + Input string `json:"input"` + TrackID string `json:"track_id"` + GID string `json:"gid"` + CanonicalURI string `json:"canonical_uri"` + Name string `json:"name"` + Artists []string `json:"artists"` + AlbumName string `json:"album_name"` + ReleaseDate string `json:"release_date"` + Label string `json:"label"` + ISRC string `json:"isrc"` +} + +func (s *SongLinkClient) lookupSpotifyISRC(spotifyTrackID string) (string, error) { + normalizedTrackID, err := extractSpotifyTrackID(spotifyTrackID) + if err != nil { + return "", err + } + + cachedISRC, err := GetCachedISRC(normalizedTrackID) + if err != nil { + fmt.Printf("Warning: failed to read ISRC cache: %v\n", err) + } else if cachedISRC != "" { + fmt.Printf("Found ISRC in cache: %s\n", cachedISRC) + return cachedISRC, nil + } + + useSpotFetchAPI, spotFetchAPIURL := GetSpotFetchAPISettings() + if useSpotFetchAPI { + isrc, resolvedTrackID, err := s.lookupSpotifyISRCViaSpotFetchAPI(normalizedTrackID, spotFetchAPIURL) + if err == nil && isrc != "" { + fmt.Printf("Found ISRC via SpotFetch API: %s\n", isrc) + cacheResolvedSpotifyTrackISRC(normalizedTrackID, resolvedTrackID, isrc) + return isrc, nil + } + if err != nil { + fmt.Printf("Warning: SpotFetch ISRC lookup failed, falling back to Spotify metadata: %v\n", err) + } + } + + payload, metadataErr := fetchSpotifyTrackRawData(s.client, normalizedTrackID) + if metadataErr == nil { + isrc, extractErr := extractSpotifyTrackISRC(payload) + if extractErr == nil { + fmt.Printf("Found ISRC via Spotify metadata: %s\n", isrc) + cacheResolvedSpotifyTrackISRC(normalizedTrackID, "", isrc) + return isrc, nil + } + metadataErr = extractErr + } + + if metadataErr != nil { + fmt.Printf("Warning: Spotify metadata ISRC lookup failed, falling back to Soundplate: %v\n", metadataErr) + } + + isrc, resolvedTrackID, soundplateErr := s.lookupSpotifyISRCViaSoundplate(normalizedTrackID) + if soundplateErr == nil && isrc != "" { + fmt.Printf("Found ISRC via Soundplate: %s\n", isrc) + cacheResolvedSpotifyTrackISRC(normalizedTrackID, resolvedTrackID, isrc) + return isrc, nil + } + + if metadataErr != nil && soundplateErr != nil { + return "", fmt.Errorf("spotify metadata lookup failed: %v | soundplate lookup failed: %w", metadataErr, soundplateErr) + } + if soundplateErr != nil { + return "", soundplateErr + } + return "", metadataErr +} + +func cacheResolvedSpotifyTrackISRC(trackID string, resolvedTrackID string, isrc string) { + if err := PutCachedISRC(trackID, isrc); err != nil { + fmt.Printf("Warning: failed to write ISRC cache: %v\n", err) + } + if resolvedTrackID != "" && resolvedTrackID != trackID { + if err := PutCachedISRC(resolvedTrackID, isrc); err != nil { + fmt.Printf("Warning: failed to write ISRC cache for resolved track ID: %v\n", err) + } + } +} + +func (s *SongLinkClient) lookupSpotifyISRCViaSpotFetchAPI(spotifyTrackID string, apiBaseURL string) (string, string, error) { + normalizedTrackID := strings.TrimSpace(spotifyTrackID) + baseURL := strings.TrimRight(strings.TrimSpace(apiBaseURL), "/") + if normalizedTrackID == "" { + return "", "", fmt.Errorf("spotify track ID is required") + } + if baseURL == "" { + return "", "", fmt.Errorf("spotfetch api url is required") + } + + requestURL := fmt.Sprintf("%s/isrc/%s", baseURL, url.PathEscape(normalizedTrackID)) + req, err := http.NewRequest(http.MethodGet, requestURL, nil) + if err != nil { + return "", "", fmt.Errorf("failed to create SpotFetch ISRC request: %w", err) + } + req.Header.Set("User-Agent", songLinkUserAgent) + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", "", fmt.Errorf("SpotFetch ISRC request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyPreview, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) + return "", "", fmt.Errorf("SpotFetch ISRC returned status %d (%s)", resp.StatusCode, strings.TrimSpace(string(bodyPreview))) + } + + var payload spotFetchISRCResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", "", fmt.Errorf("failed to decode SpotFetch ISRC response: %w", err) + } + + isrc := firstISRCMatch(payload.ISRC) + if isrc == "" { + return "", "", fmt.Errorf("ISRC missing in SpotFetch response") + } + + return isrc, strings.TrimSpace(payload.TrackID), nil +} + +func requestSpotifyBytes(client *http.Client, targetURL string, headers map[string]string) ([]byte, error) { + req, err := http.NewRequest(http.MethodGet, targetURL, nil) + if err != nil { + return nil, err + } + + for key, value := range headers { + req.Header.Set(key, value) + } + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + details := strings.TrimSpace(string(body)) + if details == "" { + details = resp.Status + } + return nil, fmt.Errorf("request failed: %s", details) + } + + return body, nil +} + +func requestSpotifyJSON(client *http.Client, targetURL string, headers map[string]string, target interface{}) error { + body, err := requestSpotifyBytes(client, targetURL, headers) + if err != nil { + return err + } + + if err := json.Unmarshal(body, target); err != nil { + return fmt.Errorf("failed to parse JSON response: %w", err) + } + + return nil +} + +func loadSpotifyCachedToken() (*spotifyAnonymousToken, error) { + cachePath, err := spotifyTokenCachePath() + if err != nil { + return nil, err + } + + body, err := os.ReadFile(cachePath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("failed to read token cache: %w", err) + } + + var token spotifyAnonymousToken + if err := json.Unmarshal(body, &token); err != nil { + return nil, fmt.Errorf("failed to read token cache: %w", err) + } + + return &token, nil +} + +func saveSpotifyCachedToken(token *spotifyAnonymousToken) error { + cachePath, err := spotifyTokenCachePath() + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil { + return fmt.Errorf("failed to create token cache directory: %w", err) + } + + body, err := json.MarshalIndent(token, "", " ") + if err != nil { + return err + } + + if err := os.WriteFile(cachePath, body, 0o644); err != nil { + return fmt.Errorf("failed to write token cache: %w", err) + } + + return nil +} + +func loadSpotifyCachedSecrets() (*spotifySecretsCache, error) { + cachePath, err := spotifySecretsCachePath() + if err != nil { + return nil, err + } + + body, err := os.ReadFile(cachePath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("failed to read secrets cache: %w", err) + } + + var cache spotifySecretsCache + if err := json.Unmarshal(body, &cache); err != nil { + return nil, fmt.Errorf("failed to parse secrets cache: %w", err) + } + + return &cache, nil +} + +func saveSpotifyCachedSecrets(cache *spotifySecretsCache) error { + cachePath, err := spotifySecretsCachePath() + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil { + return fmt.Errorf("failed to create secrets cache directory: %w", err) + } + + body, err := json.MarshalIndent(cache, "", " ") + if err != nil { + return err + } + + if err := os.WriteFile(cachePath, body, 0o644); err != nil { + return fmt.Errorf("failed to write secrets cache: %w", err) + } + + return nil +} + +func spotifyTokenCachePath() (string, error) { + appDir, err := EnsureAppDir() + if err != nil { + return "", err + } + + return filepath.Join(appDir, spotifyTokenCacheFile), nil +} + +func spotifySecretsCachePath() (string, error) { + appDir, err := EnsureAppDir() + if err != nil { + return "", err + } + + return filepath.Join(appDir, spotifySecretsCacheFile), nil +} + +func spotifyTokenIsValid(token *spotifyAnonymousToken) bool { + if token == nil || token.AccessToken == "" || token.AccessTokenExpirationTimestampMs == 0 { + return false + } + + return time.Now().UnixMilli() < token.AccessTokenExpirationTimestampMs-30_000 +} + +func spotifySecretsCacheIsValid(cache *spotifySecretsCache) bool { + if cache == nil || cache.FetchedAtUnix == 0 || len(cache.Secrets) == 0 { + return false + } + + return time.Since(time.Unix(cache.FetchedAtUnix, 0)) < spotifySecretsCacheTTL +} + +func deriveSpotifyTOTPSecret(ciphertext []int) []byte { + var builder strings.Builder + + for index, value := range ciphertext { + builder.WriteString(strconv.Itoa(value ^ ((index % 33) + 9))) + } + + return []byte(builder.String()) +} + +func generateSpotifyTOTP(secret []byte, timestampMs int64) string { + counter := timestampMs / 1000 / spotifyTOTPPeriod + counterBytes := make([]byte, 8) + binary.BigEndian.PutUint64(counterBytes, uint64(counter)) + + mac := hmac.New(sha1.New, secret) + mac.Write(counterBytes) + digest := mac.Sum(nil) + + offset := digest[len(digest)-1] & 0x0f + binaryCode := (int(digest[offset])&0x7f)<<24 | + (int(digest[offset+1])&0xff)<<16 | + (int(digest[offset+2])&0xff)<<8 | + (int(digest[offset+3]) & 0xff) + + modulo := 1 + for i := 0; i < spotifyTOTPDigits; i++ { + modulo *= 10 + } + + return fmt.Sprintf("%0*d", spotifyTOTPDigits, binaryCode%modulo) +} + +func requestSpotifyAnonymousAccessToken(client *http.Client) (string, error) { + spotifyAnonymousTokenMu.Lock() + defer spotifyAnonymousTokenMu.Unlock() + + cachedToken, err := loadSpotifyCachedToken() + if err != nil { + return "", err + } + + if spotifyTokenIsValid(cachedToken) { + return cachedToken.AccessToken, nil + } + + var serverTime spotifyServerTimeResponse + if err := requestSpotifyJSON(client, spotifyServerTimeURL, nil, &serverTime); err != nil { + return "", err + } + + var secrets map[string][]int + cachedSecrets, err := loadSpotifyCachedSecrets() + if err != nil { + fmt.Printf("Warning: failed to read Spotify secrets cache: %v\n", err) + } + + if spotifySecretsCacheIsValid(cachedSecrets) { + secrets = cachedSecrets.Secrets + } else { + if err := requestSpotifyJSON(client, spotifyTOTPSecretsURL, nil, &secrets); err != nil { + if cachedSecrets != nil && len(cachedSecrets.Secrets) > 0 { + fmt.Printf("Warning: failed to refresh Spotify secrets cache, using stale cache: %v\n", err) + secrets = cachedSecrets.Secrets + } else { + return "", err + } + } else { + cache := &spotifySecretsCache{ + FetchedAtUnix: time.Now().Unix(), + Secrets: secrets, + } + if err := saveSpotifyCachedSecrets(cache); err != nil { + fmt.Printf("Warning: failed to write Spotify secrets cache: %v\n", err) + } + } + } + + version, err := latestSpotifySecretVersion(secrets) + if err != nil { + return "", err + } + + secret := deriveSpotifyTOTPSecret(secrets[version]) + generatedTOTP := generateSpotifyTOTP(secret, serverTime.ServerTime*1000) + + query := url.Values{ + "reason": {"init"}, + "productType": {"web-player"}, + "totp": {generatedTOTP}, + "totpServer": {generatedTOTP}, + "totpVer": {version}, + } + + var token spotifyAnonymousToken + if err := requestSpotifyJSON(client, spotifySessionTokenURL+"?"+query.Encode(), nil, &token); err != nil { + return "", err + } + + if err := saveSpotifyCachedToken(&token); err != nil { + return "", err + } + + return token.AccessToken, nil +} + +func latestSpotifySecretVersion(secrets map[string][]int) (string, error) { + var ( + bestVersion string + bestNumber int + ) + + for version := range secrets { + number, err := strconv.Atoi(version) + if err != nil { + return "", fmt.Errorf("invalid secret version %q: %w", version, err) + } + if bestVersion == "" || number > bestNumber { + bestVersion = version + bestNumber = number + } + } + + if bestVersion == "" { + return "", errors.New("no TOTP secret versions available") + } + + return bestVersion, nil +} + +func extractSpotifyTrackID(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", errors.New("track input is required") + } + + if strings.HasPrefix(value, "spotify:track:") { + return value[strings.LastIndex(value, ":")+1:], nil + } + + parsed, err := url.Parse(value) + if err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") { + parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") + if len(parts) >= 2 && parts[0] == "track" { + return parts[1], nil + } + return "", errors.New("expected URL like https://open.spotify.com/track/") + } + + if len(value) == 22 { + return value, nil + } + + return "", errors.New("track must be a Spotify track ID, URL, or URI") +} + +func spotifyTrackIDToGID(trackID string) (string, error) { + if trackID == "" { + return "", errors.New("track ID is empty") + } + + value := big.NewInt(0) + base := big.NewInt(62) + + for _, char := range trackID { + index := strings.IndexRune(spotifyBase62Alphabet, char) + if index < 0 { + return "", fmt.Errorf("invalid base62 character: %q", string(char)) + } + + value.Mul(value, base) + value.Add(value, big.NewInt(int64(index))) + } + + hexValue := value.Text(16) + if len(hexValue) < 32 { + hexValue = strings.Repeat("0", 32-len(hexValue)) + hexValue + } + + return hexValue, nil +} + +func fetchSpotifyTrackRawData(client *http.Client, trackID string) ([]byte, error) { + accessToken, err := requestSpotifyAnonymousAccessToken(client) + if err != nil { + return nil, err + } + + gid, err := spotifyTrackIDToGID(trackID) + if err != nil { + return nil, err + } + + return requestSpotifyBytes( + client, + fmt.Sprintf(spotifyGIDMetadataURL, "track", gid), + map[string]string{ + "authorization": "Bearer " + accessToken, + "accept": "application/json", + }, + ) +} + +func extractSpotifyTrackISRC(payload []byte) (string, error) { + var track spotifyTrackRawData + if err := json.Unmarshal(payload, &track); err != nil { + return "", fmt.Errorf("failed to decode Spotify track metadata: %w", err) + } + + for _, externalID := range track.ExternalID { + if strings.EqualFold(strings.TrimSpace(externalID.Type), "isrc") { + if isrc := firstISRCMatch(externalID.ID); isrc != "" { + return isrc, nil + } + } + } + + if fallbackISRC := firstISRCMatch(string(payload)); fallbackISRC != "" { + return fallbackISRC, nil + } + + return "", fmt.Errorf("ISRC not found in Spotify track metadata") +} diff --git a/backend/link_resolver.go b/backend/link_resolver.go new file mode 100644 index 0000000..2c23573 --- /dev/null +++ b/backend/link_resolver.go @@ -0,0 +1,154 @@ +package backend + +import ( + "errors" + "fmt" + "strings" +) + +type resolvedTrackLinks struct { + TidalURL string + AmazonURL string + DeezerURL string + ISRC string +} + +const ( + linkResolverProviderSongstats = "songstats" + linkResolverProviderDeezerSongLink = "deezer-songlink" +) + +func (s *SongLinkClient) resolveSpotifyTrackLinks(spotifyTrackID string, region string) (*resolvedTrackLinks, error) { + links := &resolvedTrackLinks{} + var attempts []string + + isrc, err := s.lookupSpotifyISRC(spotifyTrackID) + if err != nil { + attempts = append(attempts, fmt.Sprintf("spotify isrc: %v", err)) + } else { + links.ISRC = isrc + } + + if links.ISRC != "" { + resolvers := orderedLinkResolvers() + + for _, resolver := range resolvers { + switch resolver { + case linkResolverProviderSongstats: + addedData, songstatsErr := s.resolveLinksViaSongstats(links) + if songstatsErr != nil { + attempts = append(attempts, fmt.Sprintf("songstats: %v", songstatsErr)) + } else if addedData { + fmt.Println("Using Songstats as configured link resolver") + } + case linkResolverProviderDeezerSongLink: + addedData, deezerSongLinkErr := s.resolveLinksViaDeezerSongLink(links, region) + if deezerSongLinkErr != nil { + attempts = append(attempts, fmt.Sprintf("deezer-songlink: %v", deezerSongLinkErr)) + } else if addedData { + fmt.Println("Using Songlink as configured link resolver") + } + } + + if links.TidalURL != "" && links.AmazonURL != "" { + return links, nil + } + } + } + + if hasAnySongLinkData(links) { + return links, nil + } + + if len(attempts) == 0 { + attempts = append(attempts, "no streaming URLs found") + } + + return links, errors.New(strings.Join(attempts, " | ")) +} + +func orderedLinkResolvers() []string { + preferred := GetLinkResolverSetting() + if !GetLinkResolverAllowFallback() { + if preferred == linkResolverProviderDeezerSongLink { + return []string{linkResolverProviderDeezerSongLink} + } + return []string{linkResolverProviderSongstats} + } + + if preferred == linkResolverProviderDeezerSongLink { + return []string{ + linkResolverProviderDeezerSongLink, + linkResolverProviderSongstats, + } + } + + return []string{ + linkResolverProviderSongstats, + linkResolverProviderDeezerSongLink, + } +} + +func (s *SongLinkClient) resolveLinksViaSongstats(links *resolvedTrackLinks) (bool, error) { + if links == nil || links.ISRC == "" { + return false, fmt.Errorf("ISRC is required for Songstats resolver") + } + + before := *links + + fmt.Printf("Fetching Songstats links for ISRC %s\n", links.ISRC) + if err := s.populateLinksFromSongstats(links, links.ISRC); err != nil { + return false, err + } + + return *links != before, nil +} + +func (s *SongLinkClient) resolveLinksViaDeezerSongLink(links *resolvedTrackLinks, region string) (bool, error) { + if links == nil || links.ISRC == "" { + return false, fmt.Errorf("ISRC is required for Deezer song.link resolver") + } + + before := *links + var attempts []string + + if links.DeezerURL == "" { + fmt.Printf("Resolving Deezer track from ISRC %s\n", links.ISRC) + deezerURL, err := s.lookupDeezerTrackURLByISRC(links.ISRC) + if err != nil { + attempts = append(attempts, fmt.Sprintf("deezer isrc: %v", err)) + } else { + links.DeezerURL = deezerURL + fmt.Printf("Found Deezer URL: %s\n", links.DeezerURL) + } + } + + if links.DeezerURL != "" { + fmt.Println("Resolving streaming URLs from song.link via Deezer URL...") + deezerResp, err := s.fetchSongLinkLinksByURL(links.DeezerURL, region) + if err != nil { + attempts = append(attempts, fmt.Sprintf("song.link deezer: %v", err)) + } else { + mergeSongLinkResponse(links, deezerResp) + } + + if links.ISRC == "" { + if resolvedISRC, deezerISRCErr := getDeezerISRC(links.DeezerURL); deezerISRCErr == nil { + links.ISRC = resolvedISRC + } + } + } + + if *links != before { + if len(attempts) == 0 { + return true, nil + } + return true, errors.New(strings.Join(attempts, " | ")) + } + + if len(attempts) == 0 { + attempts = append(attempts, "no links found via deezer-songlink") + } + + return false, errors.New(strings.Join(attempts, " | ")) +} diff --git a/backend/metadata.go b/backend/metadata.go index d4a9e0a..50cd52d 100644 --- a/backend/metadata.go +++ b/backend/metadata.go @@ -28,6 +28,7 @@ type Metadata struct { DiscNumber int TotalDiscs int URL string + Comment string Copyright string Publisher string Lyrics string @@ -88,6 +89,9 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error { if metadata.Description != "" { _ = cmt.Add("DESCRIPTION", metadata.Description) } + if comment := resolveMetadataComment(metadata); comment != "" { + _ = cmt.Add("COMMENT", comment) + } if metadata.ISRC != "" { _ = cmt.Add("ISRC", metadata.ISRC) @@ -166,6 +170,14 @@ func extractYear(releaseDate string) string { return releaseDate } +func resolveMetadataComment(metadata Metadata) string { + if comment := strings.TrimSpace(metadata.Comment); comment != "" { + return comment + } + + return strings.TrimSpace(metadata.URL) +} + func EmbedLyricsOnly(filepath string, lyrics string) error { if lyrics == "" { return nil @@ -891,7 +903,11 @@ func ExtractFullMetadataFromFile(filePath string) (Metadata, error) { metadata.Publisher = value case "url": metadata.URL = value - case "description", "comment": + case "comment", "comments": + if metadata.Comment == "" { + metadata.Comment = value + } + case "description": if metadata.Description == "" { metadata.Description = value } @@ -982,6 +998,16 @@ func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) er tag.AddTextFrame("TSRC", id3v2.EncodingUTF8, metadata.ISRC) } + if comment := resolveMetadataComment(metadata); comment != "" { + tag.DeleteFrames(tag.CommonID("Comments")) + tag.AddCommentFrame(id3v2.CommentFrame{ + Encoding: id3v2.EncodingUTF8, + Language: "eng", + Description: "", + Text: comment, + }) + } + if coverPath != "" && fileExists(coverPath) { tag.DeleteFrames(tag.CommonID("Attached picture")) @@ -1068,6 +1094,9 @@ func embedMetadataToM4A(filePath string, metadata Metadata, coverPath string) er if metadata.ISRC != "" { args = append(args, "-metadata", "isrc="+metadata.ISRC) } + if comment := resolveMetadataComment(metadata); comment != "" { + args = append(args, "-metadata", "comment="+comment) + } tmpOutputFile := strings.TrimSuffix(filePath, pathfilepath.Ext(filePath)) + ".tmp" + pathfilepath.Ext(filePath) defer func() { diff --git a/backend/provider_priority.go b/backend/provider_priority.go new file mode 100644 index 0000000..68377ef --- /dev/null +++ b/backend/provider_priority.go @@ -0,0 +1,215 @@ +package backend + +import ( + "encoding/json" + "fmt" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + bolt "go.etcd.io/bbolt" +) + +const ( + providerPriorityDBFile = "provider_priority.db" + providerPriorityBucket = "ProviderPriority" +) + +type providerPriorityEntry struct { + Service string `json:"service"` + Provider string `json:"provider"` + LastOutcome string `json:"last_outcome"` + LastAttempt int64 `json:"last_attempt"` + LastSuccess int64 `json:"last_success"` + LastFailure int64 `json:"last_failure"` + SuccessCount int64 `json:"success_count"` + FailureCount int64 `json:"failure_count"` +} + +var ( + providerPriorityDB *bolt.DB + providerPriorityDBMu sync.Mutex +) + +func InitProviderPriorityDB() error { + providerPriorityDBMu.Lock() + defer providerPriorityDBMu.Unlock() + + if providerPriorityDB != nil { + return nil + } + + appDir, err := EnsureAppDir() + if err != nil { + return err + } + + dbPath := filepath.Join(appDir, providerPriorityDBFile) + db, err := bolt.Open(dbPath, 0o600, &bolt.Options{Timeout: 1 * time.Second}) + if err != nil { + return err + } + + if err := db.Update(func(tx *bolt.Tx) error { + _, err := tx.CreateBucketIfNotExists([]byte(providerPriorityBucket)) + return err + }); err != nil { + db.Close() + return err + } + + providerPriorityDB = db + return nil +} + +func CloseProviderPriorityDB() { + providerPriorityDBMu.Lock() + defer providerPriorityDBMu.Unlock() + + if providerPriorityDB != nil { + _ = providerPriorityDB.Close() + providerPriorityDB = nil + } +} + +func prioritizeProviders(service string, providers []string) []string { + ordered := append([]string(nil), providers...) + if len(ordered) < 2 { + return ordered + } + + if err := InitProviderPriorityDB(); err != nil { + fmt.Printf("Warning: failed to init provider priority DB: %v\n", err) + return ordered + } + + serviceKey := strings.TrimSpace(strings.ToLower(service)) + entries := make(map[string]providerPriorityEntry, len(ordered)) + + if err := providerPriorityDB.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(providerPriorityBucket)) + if bucket == nil { + return nil + } + + for _, provider := range ordered { + if raw := bucket.Get([]byte(providerPriorityKey(serviceKey, provider))); len(raw) > 0 { + var entry providerPriorityEntry + if err := json.Unmarshal(raw, &entry); err != nil { + return err + } + entries[provider] = entry + } + } + return nil + }); err != nil { + fmt.Printf("Warning: failed to read provider priority DB: %v\n", err) + return ordered + } + + originalIndex := make(map[string]int, len(ordered)) + for idx, provider := range ordered { + originalIndex[provider] = idx + } + + sort.SliceStable(ordered, func(i, j int) bool { + left := entries[ordered[i]] + right := entries[ordered[j]] + + leftRank := providerOutcomeRank(left.LastOutcome) + rightRank := providerOutcomeRank(right.LastOutcome) + if leftRank != rightRank { + return leftRank > rightRank + } + + if left.LastSuccess != right.LastSuccess { + return left.LastSuccess > right.LastSuccess + } + + if left.LastAttempt != right.LastAttempt { + return left.LastAttempt > right.LastAttempt + } + + return originalIndex[ordered[i]] < originalIndex[ordered[j]] + }) + + return ordered +} + +func recordProviderSuccess(service string, provider string) { + recordProviderOutcome(service, provider, true) +} + +func recordProviderFailure(service string, provider string) { + recordProviderOutcome(service, provider, false) +} + +func recordProviderOutcome(service string, provider string, success bool) { + if strings.TrimSpace(service) == "" || strings.TrimSpace(provider) == "" { + return + } + + if err := InitProviderPriorityDB(); err != nil { + fmt.Printf("Warning: failed to init provider priority DB: %v\n", err) + return + } + + serviceKey := strings.TrimSpace(strings.ToLower(service)) + providerKey := providerPriorityKey(serviceKey, provider) + now := time.Now().Unix() + + if err := providerPriorityDB.Update(func(tx *bolt.Tx) error { + bucket, err := tx.CreateBucketIfNotExists([]byte(providerPriorityBucket)) + if err != nil { + return err + } + + entry := providerPriorityEntry{ + Service: serviceKey, + Provider: provider, + } + + if raw := bucket.Get([]byte(providerKey)); len(raw) > 0 { + if err := json.Unmarshal(raw, &entry); err != nil { + return err + } + } + + entry.LastAttempt = now + if success { + entry.LastOutcome = "success" + entry.LastSuccess = now + entry.SuccessCount++ + } else { + entry.LastOutcome = "failure" + entry.LastFailure = now + entry.FailureCount++ + } + + payload, err := json.Marshal(entry) + if err != nil { + return err + } + + return bucket.Put([]byte(providerKey), payload) + }); err != nil { + fmt.Printf("Warning: failed to update provider priority DB: %v\n", err) + } +} + +func providerOutcomeRank(outcome string) int { + switch strings.TrimSpace(strings.ToLower(outcome)) { + case "success": + return 2 + case "": + return 1 + default: + return 0 + } +} + +func providerPriorityKey(service string, provider string) string { + return strings.TrimSpace(strings.ToLower(service)) + "|" + strings.TrimSpace(provider) +} diff --git a/backend/qobuz.go b/backend/qobuz.go index 3501060..0117a2b 100644 --- a/backend/qobuz.go +++ b/backend/qobuz.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "io" - "math/rand" "net/http" "os" "path/filepath" @@ -171,15 +170,16 @@ 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 := []string{ + standardAPIs := prioritizeProviders("qobuz", []string{ "https://dab.yeet.su/api/stream?trackId=", "https://dabmusic.xyz/api/stream?trackId=", "https://qbz.afkarxyz.qzz.io/api/track/", - } + }) downloadFunc := func(qual string) (string, error) { type Provider struct { Name string + API string Func func() (string, error) } @@ -189,27 +189,26 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal currentAPI := api providers = append(providers, Provider{ Name: "Standard(" + currentAPI + ")", + API: currentAPI, Func: func() (string, error) { return q.DownloadFromStandard(currentAPI, trackID, qual) }, }) } - rand.Seed(time.Now().UnixNano()) - rand.Shuffle(len(providers), func(i, j int) { providers[i], providers[j] = providers[j], providers[i] }) - var lastErr error for _, p := range providers { - fmt.Printf("Trying Provider: %s (Quality: %s)...\n", p.Name, qual) url, err := p.Func() if err == nil { fmt.Printf("✓ Success\n") + recordProviderSuccess("qobuz", p.API) return url, nil } fmt.Printf("Provider failed: %v\n", err) + recordProviderFailure("qobuz", p.API) lastErr = err } return "", lastErr @@ -362,29 +361,29 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t } func (q *QobuzDownloader) DownloadTrack(spotifyID, outputDir, quality, 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, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { - var deezerISRC string + var isrc string if spotifyID != "" { - songlinkClient := NewSongLinkClient() - isrc, err := songlinkClient.GetISRCDirect(spotifyID) + linkClient := NewSongLinkClient() + resolvedISRC, err := linkClient.GetISRCDirect(spotifyID) if err != nil { return "", fmt.Errorf("failed to get ISRC: %v", err) } - deezerISRC = isrc + isrc = resolvedISRC } else { return "", fmt.Errorf("spotify ID is required for Qobuz download") } - return q.DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre) + return q.DownloadTrackWithISRC(isrc, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre) } -func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir, quality, 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, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { - fmt.Printf("Fetching track info for ISRC: %s\n", deezerISRC) +func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, 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, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { + fmt.Printf("Fetching track info for ISRC: %s\n", isrc) metaChan := make(chan Metadata, 1) - if embedGenre && deezerISRC != "" { + if embedGenre && isrc != "" { go func() { fmt.Println("Fetching MusicBrainz metadata...") - if fetchedMeta, err := FetchMusicBrainzMetadata(deezerISRC, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil { + if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil { fmt.Println("✓ MusicBrainz metadata fetched") metaChan <- fetchedMeta } else { @@ -402,7 +401,7 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir } } - track, err := q.searchByISRC(deezerISRC) + track, err := q.searchByISRC(isrc) if err != nil { return "", err } @@ -477,7 +476,7 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir } var mbMeta Metadata - if deezerISRC != "" { + if isrc != "" { mbMeta = <-metaChan } @@ -499,10 +498,11 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir DiscNumber: spotifyDiscNumber, TotalDiscs: spotifyTotalDiscs, URL: spotifyURL, + Comment: spotifyURL, Copyright: spotifyCopyright, Publisher: spotifyPublisher, Description: "https://github.com/afkarxyz/SpotiFLAC", - ISRC: deezerISRC, + ISRC: isrc, Genre: mbMeta.Genre, } diff --git a/backend/songlink.go b/backend/songlink.go index ae869b7..919fa39 100644 --- a/backend/songlink.go +++ b/backend/songlink.go @@ -2,12 +2,9 @@ package backend import ( "encoding/json" - "errors" "fmt" - "html" "io" "net/http" - "net/http/cookiejar" "net/url" "regexp" "strings" @@ -17,12 +14,9 @@ import ( const songLinkUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36" var ( - errSongLinkRateLimited = errors.New("song.link rate limited") - isrcPattern = regexp.MustCompile(`\b([A-Z]{2}[A-Z0-9]{3}\d{7})\b`) - csrfTokenPattern = regexp.MustCompile(`name=["']csrfmiddlewaretoken["'][^>]*value=["']([^"']+)["']`) - songstatsScriptPattern = regexp.MustCompile(`(?is)]+type=["']application/ld\+json["'][^>]*>(.*?)`) - amazonAlbumTrackPath = regexp.MustCompile(`/albums/[A-Z0-9]{10}/(B[0-9A-Z]{9})`) - amazonTrackPath = regexp.MustCompile(`/tracks/(B[0-9A-Z]{9})`) + isrcPattern = regexp.MustCompile(`\b([A-Z]{2}[A-Z0-9]{3}\d{7})\b`) + amazonAlbumTrackPath = regexp.MustCompile(`/albums/[A-Z0-9]{10}/(B[0-9A-Z]{9})`) + amazonTrackPath = regexp.MustCompile(`/tracks/(B[0-9A-Z]{9})`) ) type SongLinkClient struct { @@ -53,13 +47,6 @@ type songLinkAPIResponse struct { } `json:"linksByPlatform"` } -type resolvedTrackLinks struct { - TidalURL string - AmazonURL string - DeezerURL string - ISRC string -} - func NewSongLinkClient() *SongLinkClient { return &SongLinkClient{ client: &http.Client{ @@ -113,8 +100,8 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string) (*TrackAv } if isrc == "" && availability.DeezerURL != "" { - if deezerISRC, deezerErr := getDeezerISRC(availability.DeezerURL); deezerErr == nil { - isrc = deezerISRC + if resolvedISRC, deezerErr := getDeezerISRC(availability.DeezerURL); deezerErr == nil { + isrc = resolvedISRC } } @@ -145,7 +132,11 @@ func checkQobuzAvailability(isrc string) bool { client := &http.Client{Timeout: 10 * time.Second} appID := "798273057" - searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/track/search?query=%s&limit=1&app_id=%s", isrc, appID) + searchURL := fmt.Sprintf( + "https://www.qobuz.com/api.json/0.2/track/search?query=%s&limit=1&app_id=%s", + url.QueryEscape(strings.TrimSpace(isrc)), + appID, + ) resp, err := client.Get(searchURL) if err != nil { @@ -153,7 +144,7 @@ func checkQobuzAvailability(isrc string) bool { } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return false } @@ -222,7 +213,7 @@ func getDeezerISRC(deezerURL string) (string, error) { } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("Deezer API returned status %d", resp.StatusCode) } @@ -277,84 +268,13 @@ func (s *SongLinkClient) GetISRCDirect(spotifyID string) (string, error) { return s.lookupSpotifyISRC(spotifyID) } -func (s *SongLinkClient) resolveSpotifyTrackLinks(spotifyTrackID string, region string) (*resolvedTrackLinks, error) { - links := &resolvedTrackLinks{} - var attempts []string - - spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID) - - fmt.Println("Getting streaming URLs from song.link...") - resp, err := s.fetchSongLinkLinksByURL(spotifyURL, region) - if err == nil { - mergeSongLinkResponse(links, resp) - if links.DeezerURL != "" && links.ISRC == "" { - if isrc, deezerErr := getDeezerISRC(links.DeezerURL); deezerErr == nil { - links.ISRC = isrc - } - } - if hasAnySongLinkData(links) { - return links, nil - } - attempts = append(attempts, "song.link spotify: no links found") - } else { - if errors.Is(err, errSongLinkRateLimited) { - fmt.Println("song.link rate limited for Spotify URL, switching to fallback 1 (songstats)...") - } else { - fmt.Printf("song.link primary lookup failed: %v\n", err) - } - attempts = append(attempts, fmt.Sprintf("song.link spotify: %v", err)) - } - - isrc, lookupErr := s.lookupSpotifyISRC(spotifyTrackID) - if lookupErr != nil { - attempts = append(attempts, fmt.Sprintf("isrc lookup: %v", lookupErr)) - } else { - links.ISRC = isrc - } - - if links.ISRC != "" { - fmt.Printf("Fallback 1: fetching Songstats links for ISRC %s\n", links.ISRC) - if songstatsErr := s.populateLinksFromSongstats(links, links.ISRC); songstatsErr != nil { - attempts = append(attempts, fmt.Sprintf("songstats: %v", songstatsErr)) - } else if links.TidalURL != "" && links.AmazonURL != "" { - return links, nil - } - - fmt.Printf("Fallback 2: resolving Deezer track from ISRC %s\n", links.ISRC) - deezerURL, deezerErr := s.lookupDeezerTrackURLByISRC(links.ISRC) - if deezerErr != nil { - attempts = append(attempts, fmt.Sprintf("deezer isrc: %v", deezerErr)) - } else { - if links.DeezerURL == "" { - links.DeezerURL = deezerURL - } - deezerResp, deezerSongLinkErr := s.fetchSongLinkLinksByURL(deezerURL, region) - if deezerSongLinkErr != nil { - attempts = append(attempts, fmt.Sprintf("song.link deezer: %v", deezerSongLinkErr)) - } else { - mergeSongLinkResponse(links, deezerResp) - } - } - } - - if hasAnySongLinkData(links) { - return links, nil - } - - if len(attempts) == 0 { - attempts = append(attempts, "no streaming URLs found") - } - - return links, errors.New(strings.Join(attempts, " | ")) -} - func (s *SongLinkClient) fetchSongLinkLinksByURL(rawURL string, region string) (*songLinkAPIResponse, error) { apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(rawURL)) if region != "" { apiURL += fmt.Sprintf("&userCountry=%s", url.QueryEscape(region)) } - req, err := http.NewRequest("GET", apiURL, nil) + req, err := http.NewRequest(http.MethodGet, apiURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -366,9 +286,6 @@ func (s *SongLinkClient) fetchSongLinkLinksByURL(rawURL string, region string) ( } defer resp.Body.Close() - if resp.StatusCode == http.StatusTooManyRequests { - return nil, errSongLinkRateLimited - } if resp.StatusCode != http.StatusOK { bodyPreview, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) return nil, fmt.Errorf("song.link returned status %d (%s)", resp.StatusCode, strings.TrimSpace(string(bodyPreview))) @@ -394,319 +311,10 @@ func (s *SongLinkClient) fetchSongLinkLinksByURL(rawURL string, region string) ( return &parsed, nil } -func (s *SongLinkClient) lookupSpotifyISRC(spotifyTrackID string) (string, error) { - spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID) - - providers := []struct { - name string - fn func(string) (string, error) - }{ - {name: "isrcfinder", fn: s.lookupISRCViaISRCFinder}, - {name: "phpstack", fn: lookupISRCViaPHPStack}, - {name: "findmyisrc", fn: lookupISRCViaFindMyISRC}, - {name: "mixvibe", fn: lookupISRCViaMixvibe}, - } - - var errorsList []string - for _, provider := range providers { - fmt.Printf("Trying ISRC provider: %s\n", provider.name) - isrc, err := provider.fn(spotifyURL) - if err == nil && isrc != "" { - fmt.Printf("Found ISRC via %s: %s\n", provider.name, isrc) - return isrc, nil - } - - if err != nil { - errorsList = append(errorsList, fmt.Sprintf("%s: %v", provider.name, err)) - } else { - errorsList = append(errorsList, fmt.Sprintf("%s: no ISRC found", provider.name)) - } - } - - return "", errors.New(strings.Join(errorsList, " | ")) -} - -func (s *SongLinkClient) lookupISRCViaISRCFinder(spotifyURL string) (string, error) { - jar, err := cookiejar.New(nil) - if err != nil { - return "", fmt.Errorf("failed to create cookie jar: %w", err) - } - - client := &http.Client{ - Timeout: 20 * time.Second, - Jar: jar, - } - - req, err := http.NewRequest("GET", "https://www.isrcfinder.com/", nil) - if err != nil { - return "", fmt.Errorf("failed to create GET request: %w", err) - } - req.Header.Set("User-Agent", songLinkUserAgent) - req.Header.Set("Referer", "https://www.isrcfinder.com/") - req.Header.Set("Origin", "https://www.isrcfinder.com") - - resp, err := client.Do(req) - if err != nil { - return "", fmt.Errorf("failed to load isrcfinder: %w", err) - } - body, err := io.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - return "", fmt.Errorf("failed to read isrcfinder response: %w", err) - } - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("isrcfinder returned status %d", resp.StatusCode) - } - - token := extractCSRFToken(string(body)) - if token == "" { - if parsedURL, parseErr := url.Parse("https://www.isrcfinder.com/"); parseErr == nil { - for _, cookie := range jar.Cookies(parsedURL) { - if cookie.Name == "csrftoken" { - token = cookie.Value - break - } - } - } - } - if token == "" { - return "", fmt.Errorf("csrf token not found") - } - - form := url.Values{} - form.Set("csrfmiddlewaretoken", token) - form.Set("URI", spotifyURL) - - postReq, err := http.NewRequest("POST", "https://www.isrcfinder.com/", strings.NewReader(form.Encode())) - if err != nil { - return "", fmt.Errorf("failed to create POST request: %w", err) - } - postReq.Header.Set("User-Agent", songLinkUserAgent) - postReq.Header.Set("Referer", "https://www.isrcfinder.com/") - postReq.Header.Set("Origin", "https://www.isrcfinder.com") - postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - postResp, err := client.Do(postReq) - if err != nil { - return "", fmt.Errorf("failed to submit isrcfinder form: %w", err) - } - postBody, err := io.ReadAll(postResp.Body) - postResp.Body.Close() - if err != nil { - return "", fmt.Errorf("failed to read isrcfinder POST response: %w", err) - } - if postResp.StatusCode != http.StatusOK { - return "", fmt.Errorf("isrcfinder POST returned status %d", postResp.StatusCode) - } - - isrc := firstISRCMatch(string(postBody)) - if isrc == "" { - return "", fmt.Errorf("ISRC not found in isrcfinder response") - } - - return isrc, nil -} - -func lookupISRCViaPHPStack(spotifyURL string) (string, error) { - apiURL := fmt.Sprintf( - "https://phpstack-822472-6184058.cloudwaysapps.com/api/spotify.php?q=%s", - url.QueryEscape(spotifyURL), - ) - - req, err := http.NewRequest("GET", apiURL, nil) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("User-Agent", songLinkUserAgent) - req.Header.Set("Referer", "https://phpstack-822472-6184058.cloudwaysapps.com/?") - - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) - if err != nil { - return "", fmt.Errorf("phpstack request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("phpstack returned status %d", resp.StatusCode) - } - - var payload struct { - ISRC string `json:"isrc"` - } - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return "", fmt.Errorf("failed to decode phpstack response: %w", err) - } - if payload.ISRC == "" { - return "", fmt.Errorf("ISRC missing in phpstack response") - } - - return strings.ToUpper(strings.TrimSpace(payload.ISRC)), nil -} - -func lookupISRCViaFindMyISRC(spotifyURL string) (string, error) { - payloadBytes, err := json.Marshal(map[string][]string{ - "uris": []string{spotifyURL}, - }) - if err != nil { - return "", fmt.Errorf("failed to encode payload: %w", err) - } - - req, err := http.NewRequest( - "POST", - "https://lxtzsnh4l3.execute-api.ap-southeast-2.amazonaws.com/prod/find-my-isrc", - strings.NewReader(string(payloadBytes)), - ) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("User-Agent", songLinkUserAgent) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Origin", "https://www.findmyisrc.com") - req.Header.Set("Referer", "https://www.findmyisrc.com/") - - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) - if err != nil { - return "", fmt.Errorf("findmyisrc request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("findmyisrc returned status %d", resp.StatusCode) - } - - var payload []struct { - Data struct { - ISRC string `json:"isrc"` - } `json:"data"` - } - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return "", fmt.Errorf("failed to decode findmyisrc response: %w", err) - } - - for _, item := range payload { - if item.Data.ISRC != "" { - return strings.ToUpper(strings.TrimSpace(item.Data.ISRC)), nil - } - } - - return "", fmt.Errorf("ISRC missing in findmyisrc response") -} - -func lookupISRCViaMixvibe(spotifyURL string) (string, error) { - payloadBytes, err := json.Marshal(map[string]string{ - "url": spotifyURL, - }) - if err != nil { - return "", fmt.Errorf("failed to encode payload: %w", err) - } - - req, err := http.NewRequest( - "POST", - "https://tools.mixviberecords.com/api/find-isrc", - strings.NewReader(string(payloadBytes)), - ) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("User-Agent", songLinkUserAgent) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Origin", "https://tools.mixviberecords.com") - req.Header.Set("Referer", "https://tools.mixviberecords.com/isrc-finder") - - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) - if err != nil { - return "", fmt.Errorf("mixvibe request failed: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read mixvibe response: %w", err) - } - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("mixvibe returned status %d", resp.StatusCode) - } - - var payload interface{} - if err := json.Unmarshal(body, &payload); err == nil { - if isrc := findISRCInValue(payload); isrc != "" { - return isrc, nil - } - } - - if isrc := firstISRCMatch(string(body)); isrc != "" { - return isrc, nil - } - - return "", fmt.Errorf("ISRC missing in mixvibe response") -} - -func (s *SongLinkClient) populateLinksFromSongstats(links *resolvedTrackLinks, isrc string) error { - pageURL := fmt.Sprintf("https://songstats.com/%s?ref=ISRCFinder", strings.ToUpper(strings.TrimSpace(isrc))) - - req, err := http.NewRequest("GET", pageURL, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("User-Agent", songLinkUserAgent) - - resp, err := s.client.Do(req) - if err != nil { - return fmt.Errorf("failed to fetch Songstats page: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("Songstats returned status %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read Songstats response: %w", err) - } - - matches := songstatsScriptPattern.FindAllStringSubmatch(string(body), -1) - if len(matches) == 0 { - return fmt.Errorf("Songstats JSON-LD not found") - } - - found := false - for _, match := range matches { - if len(match) < 2 { - continue - } - - scriptBody := strings.TrimSpace(html.UnescapeString(match[1])) - if scriptBody == "" { - continue - } - - var payload interface{} - if err := json.Unmarshal([]byte(scriptBody), &payload); err != nil { - continue - } - - before := *links - collectSongstatsLinks(payload, links) - if *links != before { - found = true - } - } - - if !found && !hasAnySongLinkData(links) { - return fmt.Errorf("no platform links found in Songstats") - } - - return nil -} - func (s *SongLinkClient) lookupDeezerTrackURLByISRC(isrc string) (string, error) { apiURL := fmt.Sprintf("https://api.deezer.com/track/isrc:%s", strings.ToUpper(strings.TrimSpace(isrc))) - req, err := http.NewRequest("GET", apiURL, nil) + req, err := http.NewRequest(http.MethodGet, apiURL, nil) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } @@ -762,62 +370,6 @@ func mergeSongLinkResponse(links *resolvedTrackLinks, resp *songLinkAPIResponse) } } -func collectSongstatsLinks(value interface{}, links *resolvedTrackLinks) { - switch typed := value.(type) { - case map[string]interface{}: - if sameAs, ok := typed["sameAs"]; ok { - applySongstatsSameAs(sameAs, links) - } - for _, nested := range typed { - collectSongstatsLinks(nested, links) - } - case []interface{}: - for _, nested := range typed { - collectSongstatsLinks(nested, links) - } - } -} - -func applySongstatsSameAs(value interface{}, links *resolvedTrackLinks) { - switch typed := value.(type) { - case string: - assignSongstatsLink(typed, links) - case []interface{}: - for _, item := range typed { - if link, ok := item.(string); ok { - assignSongstatsLink(link, links) - } - } - } -} - -func assignSongstatsLink(rawLink string, links *resolvedTrackLinks) { - link := strings.TrimSpace(rawLink) - if link == "" { - return - } - - switch { - case strings.Contains(link, "listen.tidal.com/track"): - if links.TidalURL == "" { - links.TidalURL = link - 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") - } - } - case strings.Contains(link, "deezer.com"): - if links.DeezerURL == "" { - links.DeezerURL = normalizeDeezerTrackURL(link) - fmt.Println("✓ Deezer URL found via Songstats") - } - } -} - func normalizeAmazonMusicURL(rawURL string) string { amazonURL := strings.TrimSpace(rawURL) if amazonURL == "" { @@ -880,14 +432,6 @@ func hasAnySongLinkData(links *resolvedTrackLinks) bool { return links.TidalURL != "" || links.AmazonURL != "" || links.DeezerURL != "" } -func extractCSRFToken(body string) string { - match := csrfTokenPattern.FindStringSubmatch(body) - if len(match) < 2 { - return "" - } - return strings.TrimSpace(match[1]) -} - func firstISRCMatch(body string) string { match := isrcPattern.FindStringSubmatch(strings.ToUpper(body)) if len(match) < 2 { @@ -895,31 +439,3 @@ func firstISRCMatch(body string) string { } return strings.TrimSpace(match[1]) } - -func findISRCInValue(value interface{}) string { - switch typed := value.(type) { - case map[string]interface{}: - for key, nested := range typed { - if strings.EqualFold(key, "isrc") { - if isrc, ok := nested.(string); ok { - if normalized := firstISRCMatch(isrc); normalized != "" { - return normalized - } - } - } - if isrc := findISRCInValue(nested); isrc != "" { - return isrc - } - } - case []interface{}: - for _, nested := range typed { - if isrc := findISRCInValue(nested); isrc != "" { - return isrc - } - } - case string: - return firstISRCMatch(typed) - } - - return "" -} diff --git a/backend/songstats.go b/backend/songstats.go new file mode 100644 index 0000000..7c16b8b --- /dev/null +++ b/backend/songstats.go @@ -0,0 +1,128 @@ +package backend + +import ( + "encoding/json" + "fmt" + "html" + "io" + "net/http" + "regexp" + "strings" +) + +var songstatsScriptPattern = regexp.MustCompile(`(?is)]+type=["']application/ld\+json["'][^>]*>(.*?)`) + +func (s *SongLinkClient) populateLinksFromSongstats(links *resolvedTrackLinks, isrc string) error { + pageURL := fmt.Sprintf("https://songstats.com/%s?ref=ISRCFinder", strings.ToUpper(strings.TrimSpace(isrc))) + + req, err := http.NewRequest(http.MethodGet, pageURL, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("User-Agent", songLinkUserAgent) + + resp, err := s.client.Do(req) + if err != nil { + return fmt.Errorf("failed to fetch Songstats page: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("Songstats returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read Songstats response: %w", err) + } + + matches := songstatsScriptPattern.FindAllStringSubmatch(string(body), -1) + if len(matches) == 0 { + return fmt.Errorf("Songstats JSON-LD not found") + } + + found := false + for _, match := range matches { + if len(match) < 2 { + continue + } + + scriptBody := strings.TrimSpace(html.UnescapeString(match[1])) + if scriptBody == "" { + continue + } + + var payload interface{} + if err := json.Unmarshal([]byte(scriptBody), &payload); err != nil { + continue + } + + before := *links + collectSongstatsLinks(payload, links) + if *links != before { + found = true + } + } + + if !found && !hasAnySongLinkData(links) { + return fmt.Errorf("no platform links found in Songstats") + } + + return nil +} + +func collectSongstatsLinks(value interface{}, links *resolvedTrackLinks) { + switch typed := value.(type) { + case map[string]interface{}: + if sameAs, ok := typed["sameAs"]; ok { + applySongstatsSameAs(sameAs, links) + } + for _, nested := range typed { + collectSongstatsLinks(nested, links) + } + case []interface{}: + for _, nested := range typed { + collectSongstatsLinks(nested, links) + } + } +} + +func applySongstatsSameAs(value interface{}, links *resolvedTrackLinks) { + switch typed := value.(type) { + case string: + assignSongstatsLink(typed, links) + case []interface{}: + for _, item := range typed { + if link, ok := item.(string); ok { + assignSongstatsLink(link, links) + } + } + } +} + +func assignSongstatsLink(rawLink string, links *resolvedTrackLinks) { + link := strings.TrimSpace(rawLink) + if link == "" { + return + } + + switch { + case strings.Contains(link, "listen.tidal.com/track"): + if links.TidalURL == "" { + links.TidalURL = link + 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") + } + } + case strings.Contains(link, "deezer.com"): + if links.DeezerURL == "" { + links.DeezerURL = normalizeDeezerTrackURL(link) + fmt.Println("✓ Deezer URL found via Songstats") + } + } +} diff --git a/backend/soundplate.go b/backend/soundplate.go new file mode 100644 index 0000000..bce9c94 --- /dev/null +++ b/backend/soundplate.go @@ -0,0 +1,95 @@ +package backend + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +const ( + soundplateSpotifyAPIURL = "https://phpstack-822472-6184058.cloudwaysapps.com/api/spotify.php" + soundplateRefererURL = "https://phpstack-822472-6184058.cloudwaysapps.com/?" + soundplateUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" +) + +type soundplateSpotifyResponse struct { + Name string `json:"name"` + Artist string `json:"artist"` + Album string `json:"album"` + AlbumType string `json:"album_type"` + ArtworkURL string `json:"artwork_url"` + ISRC string `json:"isrc"` + Year string `json:"year"` + SpotifyURL string `json:"spotify_url"` +} + +func (s *SongLinkClient) lookupSpotifyISRCViaSoundplate(spotifyTrackID string) (string, string, error) { + normalizedTrackID, err := extractSpotifyTrackID(spotifyTrackID) + if err != nil { + return "", "", err + } + + spotifyTrackURL := fmt.Sprintf("https://open.spotify.com/track/%s", normalizedTrackID) + query := url.Values{} + query.Set("q", spotifyTrackURL) + + req, err := http.NewRequest(http.MethodGet, soundplateSpotifyAPIURL+"?"+query.Encode(), nil) + if err != nil { + return "", "", fmt.Errorf("failed to create Soundplate ISRC request: %w", err) + } + req.Header.Set("User-Agent", soundplateUserAgent) + req.Header.Set("Accept", "*/*") + req.Header.Set("Referer", soundplateRefererURL) + req.Header.Set("Accept-Language", "en-US,en;q=0.9,id;q=0.8") + req.Header.Set("Sec-CH-UA", "\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"") + req.Header.Set("Sec-CH-UA-Mobile", "?0") + req.Header.Set("Sec-CH-UA-Platform", "\"Windows\"") + req.Header.Set("Sec-Fetch-Dest", "empty") + req.Header.Set("Sec-Fetch-Mode", "cors") + req.Header.Set("Sec-Fetch-Site", "same-origin") + req.Header.Set("Priority", "u=1, i") + + resp, err := s.client.Do(req) + if err != nil { + return "", "", fmt.Errorf("Soundplate ISRC request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", fmt.Errorf("failed to read Soundplate ISRC response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + bodyPreview := strings.TrimSpace(string(body)) + if len(bodyPreview) > 256 { + bodyPreview = bodyPreview[:256] + } + return "", "", fmt.Errorf("Soundplate ISRC returned status %d (%s)", resp.StatusCode, bodyPreview) + } + + var payload soundplateSpotifyResponse + if err := json.Unmarshal(body, &payload); err != nil { + return "", "", fmt.Errorf("failed to decode Soundplate ISRC response: %w", err) + } + + isrc := firstISRCMatch(payload.ISRC) + if isrc == "" { + isrc = firstISRCMatch(string(body)) + } + if isrc == "" { + return "", "", fmt.Errorf("ISRC missing in Soundplate response") + } + + resolvedTrackID := "" + if payload.SpotifyURL != "" { + if trackID, err := extractSpotifyTrackID(payload.SpotifyURL); err == nil { + resolvedTrackID = trackID + } + } + + return isrc, resolvedTrackID, nil +} diff --git a/backend/tidal.go b/backend/tidal.go index 630ee7b..402c81e 100644 --- a/backend/tidal.go +++ b/backend/tidal.go @@ -6,7 +6,6 @@ import ( "encoding/xml" "fmt" "io" - "math/rand" "net/http" "os" "os/exec" @@ -86,7 +85,7 @@ func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) { "https://monochrome-api.samidy.com", "https://tidal.kinoplus.online", } - return apis, nil + return prioritizeProviders("tidal", apis), nil } func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) { @@ -552,6 +551,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo DiscNumber: spotifyDiscNumber, TotalDiscs: spotifyTotalDiscs, URL: spotifyURL, + Comment: spotifyURL, Copyright: spotifyCopyright, Publisher: spotifyPublisher, Description: "https://github.com/afkarxyz/SpotiFLAC", @@ -711,6 +711,7 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality DiscNumber: spotifyDiscNumber, TotalDiscs: spotifyTotalDiscs, URL: spotifyURL, + Comment: spotifyURL, Copyright: spotifyCopyright, Publisher: spotifyPublisher, Description: "https://github.com/afkarxyz/SpotiFLAC", @@ -906,15 +907,13 @@ func getDownloadURLRotated(apis []string, trackID int64, quality string) (string return "", "", fmt.Errorf("no APIs available") } - rand.Seed(time.Now().UnixNano()) - rand.Shuffle(len(apis), func(i, j int) { apis[i], apis[j] = apis[j], apis[i] }) - - fmt.Printf("Rotating through %d APIs...\n", len(apis)) + orderedAPIs := prioritizeProviders("tidal", apis) + fmt.Printf("Trying %d prioritized APIs...\n", len(orderedAPIs)) var lastError error var errors []string - for _, apiURL := range apis { + for _, apiURL := range orderedAPIs { fmt.Printf("Trying API: %s\n", apiURL) client := &http.Client{ @@ -925,6 +924,7 @@ func getDownloadURLRotated(apis []string, trackID int64, quality string) (string resp, err := client.Get(url) if err != nil { lastError = err + recordProviderFailure("tidal", apiURL) errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err)) continue } @@ -932,6 +932,7 @@ func getDownloadURLRotated(apis []string, trackID int64, quality string) (string if resp.StatusCode != 200 { resp.Body.Close() lastError = fmt.Errorf("HTTP %d", resp.StatusCode) + recordProviderFailure("tidal", apiURL) errors = append(errors, fmt.Sprintf("%s: %v", apiURL, lastError)) continue } @@ -940,6 +941,7 @@ func getDownloadURLRotated(apis []string, trackID int64, quality string) (string resp.Body.Close() if err != nil { lastError = err + recordProviderFailure("tidal", apiURL) errors = append(errors, fmt.Sprintf("%s: read body failed", apiURL)) continue } @@ -947,6 +949,7 @@ func getDownloadURLRotated(apis []string, trackID int64, quality string) (string var v2Response TidalAPIResponseV2 if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" { fmt.Printf("✓ Success with: %s\n", apiURL) + recordProviderSuccess("tidal", apiURL) return apiURL, "MANIFEST:" + v2Response.Data.Manifest, nil } @@ -955,12 +958,14 @@ func getDownloadURLRotated(apis []string, trackID int64, quality string) (string for _, item := range v1Responses { if item.OriginalTrackURL != "" { fmt.Printf("✓ Success with: %s\n", apiURL) + recordProviderSuccess("tidal", apiURL) return apiURL, item.OriginalTrackURL, nil } } } lastError = fmt.Errorf("no download URL or manifest in response") + recordProviderFailure("tidal", apiURL) errors = append(errors, fmt.Sprintf("%s: %v", apiURL, lastError)) } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 104fb33..bff17ac 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,6 +32,7 @@ import { useMetadata } from "@/hooks/useMetadata"; import { useLyrics } from "@/hooks/useLyrics"; import { useCover } from "@/hooks/useCover"; import { useAvailability } from "@/hooks/useAvailability"; +import { ensureApiStatusCheckStarted } from "@/lib/api-status"; import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog"; import { useDownloadProgress } from "@/hooks/useDownloadProgress"; const HISTORY_KEY = "spotiflac_fetch_history"; @@ -179,6 +180,7 @@ function App() { }; mediaQuery.addEventListener("change", handleChange); checkForUpdates(); + ensureApiStatusCheckStarted(); loadHistory(); const handleScroll = () => { setShowScrollTop(window.scrollY > 300); diff --git a/frontend/src/assets/icons/amazon-music.png b/frontend/src/assets/icons/amazon-music.png new file mode 100644 index 0000000..92d42cf Binary files /dev/null and b/frontend/src/assets/icons/amazon-music.png differ diff --git a/frontend/src/assets/icons/qobuz.png b/frontend/src/assets/icons/qobuz.png new file mode 100644 index 0000000..d4a3be1 Binary files /dev/null and b/frontend/src/assets/icons/qobuz.png differ diff --git a/frontend/src/assets/icons/songlink.ico b/frontend/src/assets/icons/songlink.ico new file mode 100644 index 0000000..4fdec81 Binary files /dev/null and b/frontend/src/assets/icons/songlink.ico differ diff --git a/frontend/src/assets/icons/songstats.png b/frontend/src/assets/icons/songstats.png new file mode 100644 index 0000000..fc8a223 Binary files /dev/null and b/frontend/src/assets/icons/songstats.png differ diff --git a/frontend/src/assets/icons/tidal.png b/frontend/src/assets/icons/tidal.png new file mode 100644 index 0000000..141e014 Binary files /dev/null and b/frontend/src/assets/icons/tidal.png differ diff --git a/frontend/src/components/AboutPage.tsx b/frontend/src/components/AboutPage.tsx index bb374eb..68ca93d 100644 --- a/frontend/src/components/AboutPage.tsx +++ b/frontend/src/components/AboutPage.tsx @@ -15,13 +15,20 @@ 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 transition-colors hover:bg-muted/50 dark:hover:bg-accent/50"; export function AboutPage() { const [activeTab, setActiveTab] = useState<"projects" | "support">("projects"); const [repoStats, setRepoStats] = useState>({}); const [copiedUsdt, setCopiedUsdt] = useState(false); useEffect(() => { const fetchRepoStats = async () => { - const CACHE_KEY = "github_repo_stats_v3"; + const CACHE_KEY = "github_repo_stats_v4"; const CACHE_DURATION = 1000 * 60 * 60; const cached = localStorage.getItem(CACHE_KEY); if (cached) { @@ -63,8 +70,10 @@ export function AboutPage() { let totalDownloads = 0; let latestDownloads = 0; let latestVersion = ""; + let latestReleaseAt = ""; if (releases.length > 0) { latestVersion = releases[0].tag_name || ""; + latestReleaseAt = releases[0].published_at || releases[0].created_at || ""; latestDownloads = releases[0].assets?.reduce((sum: number, asset: any) => sum + (asset.download_count || 0), 0) || 0; totalDownloads = releases.reduce((sum: number, release: any) => { @@ -84,6 +93,7 @@ export function AboutPage() { totalDownloads, latestDownloads, latestVersion, + latestReleaseAt, languages: topLangs, }; } @@ -121,6 +131,39 @@ export function AboutPage() { const diffYears = Math.floor(diffMonths / 12); return `${diffYears}y`; }; + const formatReleaseTimeAgo = (dateString: string): string => { + if (!dateString) { + return ""; + } + const now = Date.now(); + const releasedAt = new Date(dateString).getTime(); + if (Number.isNaN(releasedAt)) { + return ""; + } + const diffMs = Math.max(0, now - releasedAt); + const totalMinutes = Math.floor(diffMs / (1000 * 60)); + const totalHours = Math.floor(totalMinutes / 60); + const totalDays = Math.floor(totalHours / 24); + const totalMonths = Math.floor(totalDays / 30); + const totalYears = Math.floor(totalMonths / 12); + if (totalYears > 0) { + const remainingMonths = totalMonths % 12; + return remainingMonths > 0 ? `${totalYears}y ${remainingMonths}m ago` : `${totalYears}y ago`; + } + if (totalMonths > 0) { + const remainingDays = totalDays % 30; + return remainingDays > 0 ? `${totalMonths}m ${remainingDays}d ago` : `${totalMonths}m ago`; + } + if (totalDays > 0) { + const remainingHours = totalHours % 24; + return remainingHours > 0 ? `${totalDays}d ${remainingHours}h ago` : `${totalDays}d ago`; + } + if (totalHours > 0) { + const remainingMinutes = totalMinutes % 60; + return `${totalHours}h ${remainingMinutes}m ago`; + } + return `${totalMinutes}m ago`; + }; const formatNumber = (num: number): string => { if (num >= 1000) { return num.toLocaleString(); @@ -154,38 +197,72 @@ export function AboutPage() { {activeTab === "projects" && (
-
- openExternal("https://exyezed.qzz.io/")}> - - Browser Extensions & Scripts - - AudioTTS Pro - ChatGPT TTS - X - X Pro - - - - openExternal("https://spotubedl.com/")}> - - - SpotubeDL{" "} - SpotubeDL - - - Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus - with High Quality. - - - -
- openExternal("https://github.com/afkarxyz/SpotiDownloader")}> + openExternal("https://github.com/spotiverse/SpotiFLAC-Next")}> + +
+ SpotiFLAC Next +
+ {repoStats["SpotiFLAC-Next"]?.latestReleaseAt && ( + {formatReleaseTimeAgo(repoStats["SpotiFLAC-Next"].latestReleaseAt)} + )} + {repoStats["SpotiFLAC-Next"]?.latestVersion && ( + {repoStats["SpotiFLAC-Next"].latestVersion} + )} +
+
+ + SpotiFLAC Next + + + {getRepoDescription("SpotiFLAC-Next")} + +
+ {repoStats["SpotiFLAC-Next"] && ( + {repoStats["SpotiFLAC-Next"].languages?.length > 0 && (
+ {repoStats["SpotiFLAC-Next"].languages.map((lang: string) => ( + {lang} + ))} +
)} +
+ + {" "} + {formatNumber(repoStats["SpotiFLAC-Next"].stars)} + + + {" "} + {repoStats["SpotiFLAC-Next"].forks} + + + {" "} + {formatTimeAgo(repoStats["SpotiFLAC-Next"].createdAt)} + +
+
+
+ + Note +
+

+ This project was created as a thank-you to everyone who has supported SpotiFLAC on Ko-fi. +

+
+
)} +
+ openExternal("https://github.com/afkarxyz/SpotiDownloader")}>
SpotiDownloader - {repoStats["SpotiDownloader"]?.latestVersion && ( - {repoStats["SpotiDownloader"].latestVersion} - )} +
+ {repoStats["SpotiDownloader"]?.latestReleaseAt && ( + {formatReleaseTimeAgo(repoStats["SpotiDownloader"].latestReleaseAt)} + )} + {repoStats["SpotiDownloader"]?.latestVersion && ( + {repoStats["SpotiDownloader"].latestVersion} + )} +
SpotiDownloader @@ -229,63 +306,18 @@ export function AboutPage() {
)} - openExternal("https://github.com/spotiverse/SpotiFLAC-Next")}> - -
- SpotiFLAC Next - {repoStats["SpotiFLAC-Next"]?.latestVersion && ( - {repoStats["SpotiFLAC-Next"].latestVersion} - )} -
- - SpotiFLAC Next - - - {getRepoDescription("SpotiFLAC-Next")} - -
- {repoStats["SpotiFLAC-Next"] && ( - {repoStats["SpotiFLAC-Next"].languages?.length > 0 && (
- {repoStats["SpotiFLAC-Next"].languages.map((lang: string) => ( - {lang} - ))} -
)} -
- - {" "} - {formatNumber(repoStats["SpotiFLAC-Next"].stars)} - - - {" "} - {repoStats["SpotiFLAC-Next"].forks} - - - {" "} - {formatTimeAgo(repoStats["SpotiFLAC-Next"].createdAt)} - -
-
-
- - Note -
-

- SpotiFLAC Next is a separate project created as a thank-you - to everyone who has supported SpotiFLAC on Ko-fi. -

-
-
)} -
- openExternal("https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader")}> + openExternal("https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader")}>
Twitter/X Media Batch Downloader - {repoStats["Twitter-X-Media-Batch-Downloader"]?.latestVersion && ( - {repoStats["Twitter-X-Media-Batch-Downloader"].latestVersion} - )} +
+ {repoStats["Twitter-X-Media-Batch-Downloader"]?.latestReleaseAt && ( + {formatReleaseTimeAgo(repoStats["Twitter-X-Media-Batch-Downloader"].latestReleaseAt)} + )} + {repoStats["Twitter-X-Media-Batch-Downloader"]?.latestVersion && ( + {repoStats["Twitter-X-Media-Batch-Downloader"].latestVersion} + )} +
Twitter/X Media Batch Downloader @@ -332,6 +364,33 @@ export function AboutPage() {
)} +
+ openExternal("https://exyezed.qzz.io/")}> + + Browser Extensions & Scripts + + {browserExtensionItems.map((item) => (
+ {item.alt}/ + + {item.label} + +
))} +
+
+
+ openExternal("https://spotubedl.com/")}> + + + SpotubeDL{" "} + SpotubeDL + + + Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus + with High Quality. + + + +
)} diff --git a/frontend/src/components/AlbumInfo.tsx b/frontend/src/components/AlbumInfo.tsx index cb6708e..a71e280 100644 --- a/frontend/src/components/AlbumInfo.tsx +++ b/frontend/src/components/AlbumInfo.tsx @@ -206,10 +206,16 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT

Download All Separate Covers

)} - {downloadedTracks.size > 0 && ()} + {downloadedTracks.size > 0 && ( + + + + +

Open Folder

+
+
)} {isDownloading && ()} diff --git a/frontend/src/components/ApiStatusTab.tsx b/frontend/src/components/ApiStatusTab.tsx index 707a0d9..d1bb34f 100644 --- a/frontend/src/components/ApiStatusTab.tsx +++ b/frontend/src/components/ApiStatusTab.tsx @@ -1,59 +1,19 @@ -import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { RefreshCw, CheckCircle2, XCircle, Loader2 } from "lucide-react"; -import { CheckAPIStatus } from "../../wailsjs/go/main/App"; import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons"; -interface ApiSource { - id: string; - type: string; - name: string; - url: string; -} -const SOURCES: ApiSource[] = [ - { id: "tidal1", type: "tidal", name: "Tidal A", url: "https://hifi-one.spotisaver.net" }, - { id: "tidal2", type: "tidal", name: "Tidal B", url: "https://hifi-two.spotisaver.net" }, - { id: "tidal3", type: "tidal", name: "Tidal C", url: "https://eu-central.monochrome.tf" }, - { id: "tidal4", type: "tidal", name: "Tidal D", url: "https://us-west.monochrome.tf" }, - { id: "tidal5", type: "tidal", name: "Tidal E", url: "https://api.monochrome.tf" }, - { id: "tidal6", type: "tidal", name: "Tidal F", url: "https://monochrome-api.samidy.com" }, - { id: "tidal7", type: "tidal", name: "Tidal G", url: "https://tidal.kinoplus.online" }, - { id: "qobuz1", type: "qobuz", name: "Qobuz A", url: "https://dab.yeet.su" }, - { id: "qobuz2", type: "qobuz", name: "Qobuz B", url: "https://dabmusic.xyz" }, - { id: "qobuz3", type: "qbz", name: "Qobuz C", url: "https://qbz.afkarxyz.qzz.io" }, - { id: "amazon1", type: "amazon", name: "Amazon Music", url: "https://amzn.afkarxyz.qzz.io" }, -]; +import { useApiStatus } from "@/hooks/useApiStatus"; export function ApiStatusTab() { - const [statuses, setStatuses] = useState>({}); - const [isCheckingAll, setIsCheckingAll] = useState(false); - const checkStatus = async (sourceId: string, apiType: string, url: string) => { - setStatuses(prev => ({ ...prev, [sourceId]: "checking" })); - try { - const isOnline = await CheckAPIStatus(apiType, url); - setStatuses(prev => ({ ...prev, [sourceId]: isOnline ? "online" : "offline" })); - } - catch (error) { - setStatuses(prev => ({ ...prev, [sourceId]: "offline" })); - } - }; - const checkAll = async () => { - setIsCheckingAll(true); - const promises = SOURCES.map(s => checkStatus(s.id, s.type, s.url)); - await Promise.allSettled(promises); - setIsCheckingAll(false); - }; - useEffect(() => { - checkAll(); - }, []); + const { sources, statuses, isCheckingAll, refreshAll } = useApiStatus(); return (
-
- {SOURCES.map((source) => { + {sources.map((source) => { const status = statuses[source.id] || "idle"; return (
diff --git a/frontend/src/components/ArtistInfo.tsx b/frontend/src/components/ArtistInfo.tsx index bf35884..987d5ca 100644 --- a/frontend/src/components/ArtistInfo.tsx +++ b/frontend/src/components/ArtistInfo.tsx @@ -610,10 +610,16 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort

Download All Separate Covers

)} - {downloadedTracks.size > 0 && ()} + {downloadedTracks.size > 0 && ( + + + + +

Open Folder

+
+
)}
{isDownloading && ()} diff --git a/frontend/src/components/AudioAnalysisPage.tsx b/frontend/src/components/AudioAnalysisPage.tsx index 962b6b1..b31badf 100644 --- a/frontend/src/components/AudioAnalysisPage.tsx +++ b/frontend/src/components/AudioAnalysisPage.tsx @@ -1,16 +1,44 @@ -import { useState, useCallback, useRef, useEffect, type ChangeEvent, type DragEvent, type CSSProperties } from "react"; +import { useState, useCallback, useRef, useEffect, type ChangeEvent, type CSSProperties, type DragEvent } from "react"; import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Progress } from "@/components/ui/progress"; -import { Upload, ArrowLeft, Trash2, Download } from "lucide-react"; +import { Spinner } from "@/components/ui/spinner"; +import { Upload, ArrowLeft, Trash2, Download, FolderOpen, X, AlertCircle, CheckCircle2, FileMusic, ChevronDown, Play, StopCircle } from "lucide-react"; import { AudioAnalysis } from "@/components/AudioAnalysis"; -import { SpectrumVisualization } from "@/components/SpectrumVisualization"; +import { SpectrumVisualization, createSpectrogramDataURL, type SpectrumVisualizationHandle } from "@/components/SpectrumVisualization"; import { useAudioAnalysis } from "@/hooks/useAudioAnalysis"; +import type { AnalysisResult } from "@/types/api"; +import { loadAudioAnalysisPreferences } from "@/lib/audio-analysis-preferences"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; -import { SelectFile, SaveSpectrumImage } from "../../wailsjs/go/main/App"; +import { GetFileSizes, ListAudioFilesInDir, SaveSpectrumImage, SelectAudioFiles, SelectFolder } from "../../wailsjs/go/main/App"; import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime"; interface AudioAnalysisPageProps { onBack?: () => void; } +type BatchItemStatus = "pending" | "analyzing" | "success" | "error"; +type BatchItemSource = "path" | "browser"; +interface BatchAnalysisItem { + id: string; + source: BatchItemSource; + path: string; + name: string; + size: number; + status: BatchItemStatus; + error?: string; + result?: AnalysisResult; + file?: File; +} +interface QueueProgressState { + completed: number; + total: number; + fileName: string; +} +const EMPTY_PROGRESS_STATE: QueueProgressState = { + completed: 0, + total: 0, + fileName: "", +}; const SUPPORTED_AUDIO_EXTENSIONS = [".flac", ".mp3", ".m4a", ".aac"]; const SUPPORTED_AUDIO_ACCEPT = [ ".flac", @@ -51,98 +79,458 @@ function fileNameFromPath(filePath: string): string { const parts = filePath.split(/[/\\]/); return parts[parts.length - 1] || filePath; } +function browserFileId(file: File): string { + return `browser:${file.name}:${file.size}:${file.lastModified}`; +} +function downloadDataURL(dataUrl: string, fileName: string): void { + const link = document.createElement("a"); + link.href = dataUrl; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} +function formatFileSize(bytes: number): string { + if (bytes <= 0) { + return "0 B"; + } + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const index = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k))); + return `${parseFloat((bytes / Math.pow(k, index)).toFixed(1))} ${sizes[index]}`; +} +function formatDuration(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; +} +function itemMetaLine(item: BatchAnalysisItem): string { + if (item.result) { + const parts = [ + item.result.file_type ?? "Audio", + `${(item.result.sample_rate / 1000).toFixed(1)} kHz`, + formatDuration(item.result.duration), + ]; + if (typeof item.result.bitrate_kbps === "number" && item.result.bitrate_kbps > 0) { + parts.push(`${item.result.bitrate_kbps} kbps`); + } + return parts.join(" • "); + } + switch (item.status) { + case "analyzing": + return "Analyzing audio quality..."; + case "error": + return item.error || "Analysis failed"; + case "pending": + default: + return "Waiting to be analyzed"; + } +} +function statusIcon(status: BatchItemStatus) { + switch (status) { + case "analyzing": + return ; + case "success": + return ; + case "error": + return ; + case "pending": + default: + return ; + } +} export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { - const { analyzing, analysisProgress, result, analyzeFile, analyzeFilePath, clearResult, selectedFilePath, spectrumLoading, spectrumProgress, reAnalyzeSpectrum, } = useAudioAnalysis(); + const { analysisProgress, spectrumLoading, spectrumProgress, analyzeFile, analyzeFilePath, cancelAnalysis, loadStoredAnalysis, clearStoredAnalysis, reAnalyzeSpectrum, clearResult, } = useAudioAnalysis(); + const [items, setItems] = useState([]); + const [activeItemId, setActiveItemId] = useState(null); const [isDragging, setIsDragging] = useState(false); - const [isExporting, setIsExporting] = useState(false); + const [isExportingSelected, setIsExportingSelected] = useState(false); + const [isExportingBatch, setIsExportingBatch] = useState(false); + const [isBatchRunning, setIsBatchRunning] = useState(false); + const [batchProgress, setBatchProgress] = useState(EMPTY_PROGRESS_STATE); + const [exportProgress, setExportProgress] = useState(EMPTY_PROGRESS_STATE); const fileInputRef = useRef(null); - const spectrumRef = useRef<{ - getCanvasDataURL: () => string | null; - }>(null); - const analyzeSelectedPath = useCallback(async (filePath: string) => { - if (!isSupportedAudioPath(filePath)) { - toast.error("Invalid File Type", { - description: `Please select a ${SUPPORTED_AUDIO_LABEL} file for analysis`, - }); + const spectrumRef = useRef(null); + const batchRunIdRef = useRef(0); + const itemsRef = useRef(items); + const activeItemIdRef = useRef(activeItemId); + useEffect(() => { + itemsRef.current = items; + }, [items]); + useEffect(() => { + activeItemIdRef.current = activeItemId; + }, [activeItemId]); + const setActiveSelection = useCallback((nextId: string | null) => { + activeItemIdRef.current = nextId; + setActiveItemId(nextId); + }, []); + const activeItem = items.find((item) => item.id === activeItemId) ?? null; + const successItems = items.filter((item) => item.status === "success" && item.result?.spectrum); + const pendingItems = items.filter((item) => item.status === "pending"); + const isSingleMode = items.length === 1; + const isBatchMode = items.length > 1; + const canResumeBatch = isBatchMode && !isBatchRunning && pendingItems.length > 0; + const batchPercent = batchProgress.total > 0 + ? Math.round(Math.max(0, Math.min(100, ((batchProgress.completed + (isBatchRunning ? analysisProgress.percent / 100 : 0)) / batchProgress.total) * 100))) + : 0; + const exportPercent = exportProgress.total > 0 + ? Math.round(Math.max(0, Math.min(100, (exportProgress.completed / exportProgress.total) * 100))) + : 0; + useEffect(() => { + if (!activeItem?.result) { return; } - await analyzeFilePath(filePath); - }, [analyzeFilePath]); - const analyzeSelectedFile = useCallback(async (file: File) => { - if (!isSupportedAudioFile(file)) { - toast.error("Invalid File Type", { - description: `Please select a ${SUPPORTED_AUDIO_LABEL} file for analysis`, - }); + loadStoredAnalysis(activeItem.id, activeItem.result, activeItem.path); + }, [activeItem, loadStoredAnalysis]); + const runBatchAnalysis = useCallback(async (entries: BatchAnalysisItem[]) => { + if (entries.length === 0) { return; } - await analyzeFile(file); - }, [analyzeFile]); - const handleSelectFile = useCallback(async () => { + const runId = batchRunIdRef.current + 1; + batchRunIdRef.current = runId; + setIsBatchRunning(true); + setBatchProgress({ + completed: 0, + total: entries.length, + fileName: entries[0]?.name ?? "", + }); + let successCount = 0; + let failCount = 0; try { - const filePath = await SelectFile(); - if (!filePath) { - return; + for (let index = 0; index < entries.length; index++) { + if (batchRunIdRef.current !== runId) { + return; + } + const entry = entries[index]; + setBatchProgress({ + completed: index, + total: entries.length, + fileName: entry.name, + }); + setItems((prev) => prev.map((item) => item.id === entry.id + ? { ...item, status: "analyzing", error: undefined } + : item)); + const outcome = entry.source === "browser" && entry.file + ? await analyzeFile(entry.file, { + analysisKey: entry.id, + displayPath: entry.path, + suppressToast: true, + }) + : await analyzeFilePath(entry.path, { + analysisKey: entry.id, + displayPath: entry.path, + suppressToast: true, + }); + if (batchRunIdRef.current !== runId) { + return; + } + if (outcome.cancelled) { + return; + } + if (outcome.result) { + const analysisResult = outcome.result; + successCount++; + setItems((prev) => prev.map((item) => item.id === entry.id + ? { + ...item, + status: "success", + error: undefined, + result: analysisResult, + size: analysisResult.file_size || item.size, + } + : item)); + const hasSelectedSuccess = itemsRef.current.some((item) => item.id === activeItemIdRef.current && item.status === "success" && item.result); + if (!hasSelectedSuccess) { + setActiveSelection(entry.id); + } + } + else { + failCount++; + setItems((prev) => prev.map((item) => item.id === entry.id + ? { + ...item, + status: "error", + error: outcome.error || "Analysis failed", + } + : item)); + if (!activeItemIdRef.current) { + setActiveSelection(entry.id); + } + } } - await analyzeSelectedPath(filePath); + if (batchRunIdRef.current === runId) { + setBatchProgress({ + completed: entries.length, + total: entries.length, + fileName: "", + }); + if (successCount > 0) { + toast.success("Batch Analysis Complete", { + description: `Successfully analyzed ${successCount} file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`, + }); + } + else if (failCount > 0) { + toast.error("Batch Analysis Failed", { + description: `All ${failCount} file(s) failed to analyze`, + }); + } + } + } + finally { + if (batchRunIdRef.current === runId) { + setIsBatchRunning(false); + } + } + }, [analyzeFile, analyzeFilePath, setActiveSelection]); + const ensureIdleQueue = useCallback(() => { + if (!isBatchRunning) { + return true; + } + toast.info("Analysis in progress", { + description: "Please wait for the current batch to finish or clear it first.", + }); + return false; + }, [isBatchRunning]); + const addPathItems = useCallback(async (paths: string[]) => { + if (!ensureIdleQueue()) { + return; + } + const uniquePaths = Array.from(new Set(paths.filter(Boolean))); + const invalidCount = uniquePaths.filter((path) => !isSupportedAudioPath(path)).length; + const validPaths = uniquePaths.filter(isSupportedAudioPath); + if (invalidCount > 0) { + toast.error("Unsupported format", { + description: `Only ${SUPPORTED_AUDIO_LABEL} files can be analyzed.`, + }); + } + if (validPaths.length === 0) { + return; + } + const existingIds = new Set(itemsRef.current.map((item) => item.id)); + const newPaths = validPaths.filter((path) => !existingIds.has(path)); + if (newPaths.length === 0) { + toast.info("No new files added", { + description: "All selected files were already in the batch queue.", + }); + return; + } + const fileSizes = await GetFileSizes(newPaths); + const newItems = newPaths.map((path) => ({ + id: path, + source: "path" as const, + path, + name: fileNameFromPath(path), + size: fileSizes[path] || 0, + status: "pending" as const, + })); + if (validPaths.length !== newPaths.length) { + toast.info("Some files skipped", { + description: `${validPaths.length - newPaths.length} file(s) were already queued.`, + }); + } + setItems((prev) => [...prev, ...newItems]); + if (!activeItemIdRef.current) { + setActiveSelection(newItems[0]?.id ?? null); + } + void runBatchAnalysis(newItems); + }, [ensureIdleQueue, runBatchAnalysis, setActiveSelection]); + const addBrowserFiles = useCallback(async (files: File[]) => { + if (!ensureIdleQueue()) { + return; + } + const validFiles = files.filter(isSupportedAudioFile); + const invalidCount = files.length - validFiles.length; + if (invalidCount > 0) { + toast.error("Unsupported format", { + description: `Only ${SUPPORTED_AUDIO_LABEL} files can be analyzed.`, + }); + } + if (validFiles.length === 0) { + return; + } + const existingIds = new Set(itemsRef.current.map((item) => item.id)); + const newItems = validFiles + .map((file) => ({ + id: browserFileId(file), + source: "browser" as const, + path: file.name, + name: file.name, + size: file.size, + status: "pending" as const, + file, + })) + .filter((item) => !existingIds.has(item.id)); + if (newItems.length === 0) { + toast.info("No new files added", { + description: "All selected files were already in the batch queue.", + }); + return; + } + if (validFiles.length !== newItems.length) { + toast.info("Some files skipped", { + description: `${validFiles.length - newItems.length} file(s) were already queued.`, + }); + } + setItems((prev) => [...prev, ...newItems]); + if (!activeItemIdRef.current) { + setActiveSelection(newItems[0]?.id ?? null); + } + void runBatchAnalysis(newItems); + }, [ensureIdleQueue, runBatchAnalysis, setActiveSelection]); + const handleSelectFiles = useCallback(async () => { + if (!ensureIdleQueue()) { + return; + } + try { + const selectedPaths = await SelectAudioFiles(); + if (selectedPaths && selectedPaths.length > 0) { + await addPathItems(selectedPaths); + } + return; } catch { fileInputRef.current?.click(); + return; } - }, [analyzeSelectedPath]); - const handleInputChange = useCallback(async (e: ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) + }, [addPathItems, ensureIdleQueue]); + const handleSelectFolder = useCallback(async () => { + if (!ensureIdleQueue()) { return; - await analyzeSelectedFile(file); - e.target.value = ""; - }, [analyzeSelectedFile]); - const handleHtmlDrop = useCallback(async (e: DragEvent) => { - e.preventDefault(); + } + try { + const selectedFolder = await SelectFolder(""); + if (!selectedFolder) { + return; + } + const folderFiles = await ListAudioFilesInDir(selectedFolder); + if (!folderFiles || folderFiles.length === 0) { + toast.info("No audio files found", { + description: `No ${SUPPORTED_AUDIO_LABEL} files were found in the selected folder.`, + }); + return; + } + await addPathItems(folderFiles.map((file) => file.path)); + } + catch (err) { + toast.error("Folder Selection Failed", { + description: err instanceof Error ? err.message : "Failed to select folder", + }); + } + }, [addPathItems, ensureIdleQueue]); + const handleInputChange = useCallback(async (event: ChangeEvent) => { + const files = Array.from(event.target.files ?? []); + event.target.value = ""; + if (files.length === 0) { + return; + } + await addBrowserFiles(files); + }, [addBrowserFiles]); + const handleHtmlDrop = useCallback(async (event: DragEvent) => { + event.preventDefault(); setIsDragging(false); - const file = e.dataTransfer.files?.[0]; - if (!file) + const files = Array.from(event.dataTransfer.files ?? []); + if (files.length === 0) { return; - await analyzeSelectedFile(file); - }, [analyzeSelectedFile]); + } + await addBrowserFiles(files); + }, [addBrowserFiles]); useEffect(() => { OnFileDrop((_x, _y, paths) => { setIsDragging(false); - const droppedPath = paths?.[0]; - if (!droppedPath) + if (!paths || paths.length === 0) { return; - void analyzeSelectedPath(droppedPath); + } + void addPathItems(paths); }, true); return () => { OnFileDropOff(); }; - }, [analyzeSelectedPath]); - const handleExport = useCallback(async () => { - if (!spectrumRef.current) - return; - const dataUrl = spectrumRef.current.getCanvasDataURL(); - if (!dataUrl) { - toast.error("Export Failed", { description: "Cannot get canvas data" }); + }, [addPathItems]); + const handleSelectItem = useCallback((itemId: string) => { + setActiveSelection(itemId); + }, [setActiveSelection]); + const handleRemoveItem = useCallback((itemId: string) => { + if (isBatchRunning || isExportingBatch || isExportingSelected || spectrumLoading) { return; } - setIsExporting(true); + clearStoredAnalysis(itemId); + const nextItems = itemsRef.current.filter((item) => item.id !== itemId); + itemsRef.current = nextItems; + setItems(nextItems); + if (activeItemIdRef.current === itemId) { + const nextActive = nextItems.find((item) => item.status === "success" && item.result) ?? nextItems[0] ?? null; + setActiveSelection(nextActive?.id ?? null); + if (!nextActive) { + clearResult(); + } + } + }, [clearResult, clearStoredAnalysis, isBatchRunning, isExportingBatch, isExportingSelected, setActiveSelection, spectrumLoading]); + const handleClearAll = useCallback(() => { + if (isExportingBatch || isExportingSelected) { + return; + } + batchRunIdRef.current += 1; + itemsRef.current = []; + setItems([]); + setActiveSelection(null); + clearStoredAnalysis(); + clearResult(); + setIsBatchRunning(false); + setBatchProgress(EMPTY_PROGRESS_STATE); + setExportProgress(EMPTY_PROGRESS_STATE); + setIsDragging(false); + }, [clearResult, clearStoredAnalysis, isExportingBatch, isExportingSelected, setActiveSelection]); + const handleStopBatch = useCallback(() => { + if (!isBatchRunning) { + return; + } + batchRunIdRef.current += 1; + cancelAnalysis(); + setIsBatchRunning(false); + setBatchProgress(EMPTY_PROGRESS_STATE); + setItems((prev) => prev.map((item) => item.status === "analyzing" + ? { + ...item, + status: "pending", + } + : item)); + toast.info("Batch analysis stopped", { + description: "Click Analyze to continue the remaining files.", + }); + }, [cancelAnalysis, isBatchRunning]); + const handleAnalyzePending = useCallback(() => { + if (isBatchRunning || isExportingBatch || isExportingSelected || spectrumLoading) { + return; + } + const nextPendingItems = itemsRef.current.filter((item) => item.status === "pending"); + if (nextPendingItems.length === 0) { + return; + } + void runBatchAnalysis(nextPendingItems); + }, [isBatchRunning, isExportingBatch, isExportingSelected, runBatchAnalysis, spectrumLoading]); + const handleExportSelected = useCallback(async () => { + if (!activeItem?.result?.spectrum || !spectrumRef.current) { + return; + } + const dataUrl = spectrumRef.current.getCanvasDataURL(); + if (!dataUrl) { + toast.error("Export Failed", { + description: "Cannot get canvas data", + }); + return; + } + setIsExportingSelected(true); try { - if (selectedFilePath && isAbsolutePath(selectedFilePath)) { - const outPath = await SaveSpectrumImage(selectedFilePath, dataUrl); - toast.success("Exported Successfully", { + if (activeItem.source === "path" && isAbsolutePath(activeItem.path)) { + const outPath = await SaveSpectrumImage(activeItem.path, dataUrl); + toast.success("PNG Exported", { description: `Saved to: ${outPath}`, }); return; } - const base = selectedFilePath - ? fileNameFromPath(selectedFilePath).replace(/\.[^/.]+$/, "") - : "spectrogram"; - const a = document.createElement("a"); - a.href = dataUrl; - a.download = `${base}_spectrogram.png`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - toast.success("Exported Successfully", { + const baseName = activeItem.name.replace(/\.[^/.]+$/, "") || "spectrogram"; + downloadDataURL(dataUrl, `${baseName}_spectrogram.png`); + toast.success("PNG Exported", { description: "Spectrogram image downloaded", }); } @@ -152,42 +540,228 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { }); } finally { - setIsExporting(false); + setIsExportingSelected(false); } - }, [selectedFilePath]); - const handleAnalyzeAnother = () => { - clearResult(); - }; - const fileName = selectedFilePath ? fileNameFromPath(selectedFilePath) : undefined; - return (
- + }, [activeItem]); + const handleBatchExport = useCallback(async () => { + const exportableItems = itemsRef.current.filter((item) => item.status === "success" && item.result?.spectrum); + if (exportableItems.length === 0) { + toast.error("Nothing to export", { + description: "Analyze at least one file successfully before exporting PNGs.", + }); + return; + } + const preferences = loadAudioAnalysisPreferences(); + setIsExportingBatch(true); + setExportProgress({ + completed: 0, + total: exportableItems.length, + fileName: exportableItems[0]?.name ?? "", + }); + let successCount = 0; + let failCount = 0; + try { + for (let index = 0; index < exportableItems.length; index++) { + const item = exportableItems[index]; + const result = item.result; + if (!result?.spectrum) { + failCount++; + continue; + } + setExportProgress({ + completed: index, + total: exportableItems.length, + fileName: item.name, + }); + try { + const dataUrl = await createSpectrogramDataURL({ + spectrumData: result.spectrum, + sampleRate: result.sample_rate, + duration: result.duration, + freqScale: preferences.freqScale, + colorScheme: preferences.colorScheme, + fileName: item.name, + }); + if (item.source === "path" && isAbsolutePath(item.path)) { + await SaveSpectrumImage(item.path, dataUrl); + } + else { + const baseName = item.name.replace(/\.[^/.]+$/, "") || "spectrogram"; + downloadDataURL(dataUrl, `${baseName}_spectrogram.png`); + } + successCount++; + } + catch { + failCount++; + } + await new Promise((resolve) => setTimeout(resolve, 0)); + } + setExportProgress({ + completed: exportableItems.length, + total: exportableItems.length, + fileName: "", + }); + if (successCount > 0) { + toast.success("Batch PNG Export Complete", { + description: `Exported ${successCount} spectrogram PNG file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`, + }); + } + else { + toast.error("Batch PNG Export Failed", { + description: "No spectrogram PNG files were exported.", + }); + } + } + finally { + setIsExportingBatch(false); + } + }, []); + const handleReAnalyzeSelectedSpectrum = useCallback(async (fftSize: number, windowFunction: string) => { + if (!activeItem?.result) { + return; + } + const nextResult = await reAnalyzeSpectrum(fftSize, windowFunction); + if (!nextResult) { + return; + } + setItems((prev) => prev.map((item) => item.id === activeItem.id + ? { + ...item, + result: nextResult, + status: "success", + error: undefined, + } + : item)); + }, [activeItem, reAnalyzeSpectrum]); + const batchDetailContent = !activeItem ? ( + +

+ Select a file from the batch queue to inspect its analysis result. +

+
+
) : activeItem.status !== "success" || !activeItem.result ? ( + + {activeItem.name} +

{activeItem.path}

+
+ + {activeItem.status === "analyzing" && (
+
+ + Analyzing audio quality... +
+ +

{analysisProgress.message}

+
)} + {activeItem.status === "pending" && (

+ This file is queued and waiting for batch analysis to start. +

)} + {activeItem.status === "error" && (
+ {activeItem.error || "Analysis failed"} +
)} +
+
) : (
+ -
+ +
); + const singleModeContent = !activeItem ? null : activeItem.status === "success" && activeItem.result ? (
+ + + +
) : activeItem.status === "analyzing" || activeItem.status === "pending" ? (
+
+
+ {activeItem.status === "pending" ? "Preparing..." : "Processing..."} + {analysisProgress.percent}% +
+ +

{analysisProgress.message}

+
+
) : (
+
+ {activeItem.error || "Analysis failed"} +
+
); + const showSingleModeActions = isSingleMode && activeItem?.status === "success" && activeItem.result; + return (
+ + +
{onBack && ()}

Audio Quality Analyzer

- {result && (
- )} + {canResumeBatch && ()} + {isBatchMode && ( + + + + + + + Add Files + + + + Add Folder + + + )} + {showSingleModeActions && ( - )} + {isBatchMode && ( + + + + + + + Export Selected PNG + + + + Export All PNG + + + )} + {showSingleModeActions && ( -
)} + )} + {isBatchMode && ()} +
- {!result && !analyzing && (
{ - e.preventDefault(); + {items.length === 0 && (
{ + event.preventDefault(); setIsDragging(true); - }} onDragLeave={(e) => { - e.preventDefault(); + }} onDragLeave={(event) => { + event.preventDefault(); setIsDragging(false); }} onDrop={handleHtmlDrop} style={{ "--wails-drop-target": "drop" } as CSSProperties}>
@@ -195,32 +769,116 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {

{isDragging - ? "Drop your audio file here" - : "Drag and drop an audio file here, or click the button below to select"} + ? "Drop your audio files here" + : "Drag and drop audio files here, or click the button below to select"}

- +
+ + +

Supported formats: FLAC, MP3, M4A, AAC

)} - {analyzing && !result && (
-
-
- Processing... - {analysisProgress.percent}% -
- -
+ {isSingleMode && (
+ {singleModeContent}
)} - {result && (
- + {isBatchMode && (
+
+ {(isBatchRunning || isExportingBatch) && ( + + + {isExportingBatch ? "Batch PNG Export" : "Batch Analysis"} + + + +
+ + {isExportingBatch + ? exportProgress.fileName || "Preparing export..." + : batchProgress.fileName || analysisProgress.message} + + + {isExportingBatch + ? `${exportProgress.completed}/${exportProgress.total}` + : `${Math.min(batchProgress.completed + (isBatchRunning ? 1 : 0), batchProgress.total)}/${batchProgress.total}`} + +
+ + {!isExportingBatch && (
+ {analysisProgress.message} + {analysisProgress.percent}% +
)} +
+
)} - + + +
+ Batch Queue +

+ {items.length} queued • {successItems.length} ready +

+
+
+ +
+ {items.map((item) => { + const isActive = item.id === activeItemId; + const isSelectable = item.status !== "pending"; + return (
{ + if (!isSelectable) { + return; + } + handleSelectItem(item.id); + }} onKeyDown={(event) => { + if (!isSelectable) { + return; + } + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleSelectItem(item.id); + } + }}> +
{statusIcon(item.status)}
+
+

{item.name}

+

+ {itemMetaLine(item)} +

+
+ {formatFileSize(item.size)} + {fileNameFromPath(item.path).split(".").pop()?.toUpperCase() || "AUDIO"} +
+
+ +
); + })} +
+
+
+
+ +
+ {batchDetailContent} +
)}
); } diff --git a/frontend/src/components/PlatformIcons.tsx b/frontend/src/components/PlatformIcons.tsx index c508105..3e98ea0 100644 --- a/frontend/src/components/PlatformIcons.tsx +++ b/frontend/src/components/PlatformIcons.tsx @@ -1,18 +1,77 @@ -export const TidalIcon = ({ className = "w-4 h-4" }: { +import amazonMusicIcon from "../assets/icons/amazon-music.png"; +import qobuzIcon from "../assets/icons/qobuz.png"; +import tidalIcon from "../assets/icons/tidal.png"; +const PLATFORM_ICON_URLS = { + tidal: tidalIcon, + qobuz: qobuzIcon, + amazon: amazonMusicIcon, +} as const; +type PlatformIconProps = { className?: string; -}) => ( - - - ); -export const QobuzIcon = ({ className = "w-4 h-4" }: { +}; +function sanitizeClassName(className: string): string { + return className + .split(/\s+/) + .filter(Boolean) + .filter((part) => part !== "fill-current" && part !== "fill-muted-foreground" && !part.startsWith("text-")) + .join(" "); +} +function hasRoundedClass(className: string): boolean { + return className + .split(/\s+/) + .some((part) => part.startsWith("rounded")); +} +function getStatusClasses(className: string): string { + if (className.includes("text-green-500")) { + return "ring-2 ring-green-500 rounded-sm"; + } + if (className.includes("text-red-500")) { + return "ring-2 ring-red-500 rounded-sm opacity-70"; + } + return ""; +} +function PlatformIcon({ src, alt, className = "w-4 h-4", defaultClassName = "" }: { + src: string; + alt: string; className?: string; -}) => ( - - - ); -export const AmazonIcon = ({ className = "w-4 h-4" }: { - className?: string; -}) => ( - - - ); + defaultClassName?: string; +}) { + const cleanedClassName = sanitizeClassName(className); + const statusClasses = getStatusClasses(className); + const imageClassName = [ + cleanedClassName || "w-4 h-4", + "inline-block shrink-0 object-contain", + !hasRoundedClass(cleanedClassName) ? defaultClassName : "", + statusClasses, + ] + .filter(Boolean) + .join(" "); + return {alt}; +} +export function TidalIcon({ className = "w-4 h-4" }: PlatformIconProps) { + return ; +} +export function QobuzIcon({ className = "w-4 h-4" }: PlatformIconProps) { + return ; +} +export function AmazonIcon({ className = "w-4 h-4" }: PlatformIconProps) { + return ; +} +export function TidalAvailabilityIcon({ className = "w-4 h-4" }: PlatformIconProps) { + return + + + ; +} +export function QobuzAvailabilityIcon({ className = "w-4 h-4" }: PlatformIconProps) { + return + + + ; +} +export function AmazonAvailabilityIcon({ className = "w-4 h-4" }: PlatformIconProps) { + return + + + ; +} diff --git a/frontend/src/components/PlaylistInfo.tsx b/frontend/src/components/PlaylistInfo.tsx index 5748f2f..50af90e 100644 --- a/frontend/src/components/PlaylistInfo.tsx +++ b/frontend/src/components/PlaylistInfo.tsx @@ -216,10 +216,16 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel

Download All Separate Covers

)} - {downloadedTracks.size > 0 && ()} + {downloadedTracks.size > 0 && ( + + + + +

Open Folder

+
+
)}
{isDownloading && ()}
diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx index 9614b13..8ee2e50 100644 --- a/frontend/src/components/SearchBar.tsx +++ b/frontend/src/components/SearchBar.tsx @@ -11,6 +11,7 @@ import { SearchSpotify, SearchSpotifyByType } from "../../wailsjs/go/main/App"; import { backend } from "../../wailsjs/go/models"; import { cn } from "@/lib/utils"; import { useTypingEffect } from "@/hooks/useTypingEffect"; +import { getSettings, type Settings } from "@/lib/settings"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; const FETCH_PLACEHOLDERS = [ @@ -245,6 +246,7 @@ interface SearchBarProps { export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, region, onRegionChange, }: SearchBarProps) { const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState(null); + const [showRegionSelector, setShowRegionSelector] = useState(() => getSettings().linkResolver === "songlink"); const [resultFilter, setResultFilter] = useState(""); const [sortOrders, setSortOrders] = useState>({ tracks: "default", @@ -279,6 +281,18 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist console.error("Failed to load recent searches:", error); } }, []); + useEffect(() => { + const syncRegionVisibility = (settings?: Partial) => { + const resolver = settings?.linkResolver ?? getSettings().linkResolver; + setShowRegionSelector(resolver === "songlink"); + }; + syncRegionVisibility(); + const handleSettingsUpdate = (event: Event) => { + syncRegionVisibility((event as CustomEvent>).detail); + }; + window.addEventListener("settingsUpdated", handleSettingsUpdate); + return () => window.removeEventListener("settingsUpdated", handleSettingsUpdate); + }, []); const saveRecentSearch = (query: string) => { const trimmed = query.trim(); if (!trimmed) @@ -589,19 +603,19 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
{!searchMode && (<> - + {showRegionSelector && ()}
+
+ +
+ + +
+ setTempSettings((prev) => ({ + ...prev, + allowResolverFallback: checked, + }))}/> + +
+
+
+
@@ -260,19 +283,19 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin Auto - + Tidal - + Qobuz - + Amazon Music diff --git a/frontend/src/components/SpectrumVisualization.tsx b/frontend/src/components/SpectrumVisualization.tsx index af4e15a..6b9d288 100644 --- a/frontend/src/components/SpectrumVisualization.tsx +++ b/frontend/src/components/SpectrumVisualization.tsx @@ -7,6 +7,18 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from " export interface SpectrumVisualizationHandle { getCanvasDataURL: () => string | null; } +type ColorScheme = AnalyzerColorScheme; +type FreqScale = AnalyzerFreqScale; +type WindowFunction = AnalyzerWindowFunction; +export interface SpectrogramRenderOptions { + spectrumData: SpectrumData; + sampleRate: number; + duration: number; + freqScale: FreqScale; + colorScheme: ColorScheme; + fileName?: string; + shouldCancel?: () => boolean; +} interface SpectrumVisualizationProps { sampleRate: number; duration: number; @@ -19,9 +31,6 @@ interface SpectrumVisualizationProps { message: string; }; } -type ColorScheme = AnalyzerColorScheme; -type FreqScale = AnalyzerFreqScale; -type WindowFunction = AnalyzerWindowFunction; const MARGIN = { top: 50, right: 120, bottom: 70, left: 90 }; const CANVAS_W = 1100; const CANVAS_H = 600; @@ -420,6 +429,20 @@ async function renderSpectrogram(ctx: CanvasRenderingContext2D, spectrum: Spectr addAxisLabels(ctx, plotWidth, plotHeight, sampleRate, duration, freqScale, fileName); drawColorBar(ctx, plotHeight, colorScheme); } +export async function renderSpectrogramToCanvas(canvas: HTMLCanvasElement, options: SpectrogramRenderOptions): Promise { + canvas.width = CANVAS_W; + canvas.height = CANVAS_H; + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("Cannot get 2D canvas context"); + } + await renderSpectrogram(ctx, options.spectrumData, options.sampleRate, options.duration, options.freqScale, options.colorScheme, options.fileName, options.shouldCancel ?? (() => false)); +} +export async function createSpectrogramDataURL(options: SpectrogramRenderOptions): Promise { + const canvas = document.createElement("canvas"); + await renderSpectrogramToCanvas(canvas, options); + return canvas.toDataURL("image/png"); +} const COLOR_SCHEMES: { value: ColorScheme; label: string; @@ -468,7 +491,15 @@ export const SpectrumVisualization = forwardRef canceled; if (spectrumData) { - void renderSpectrogram(ctx, spectrumData, sampleRate, duration, freqScale, colorScheme, fileName, shouldCancel); + void renderSpectrogramToCanvas(canvas, { + spectrumData, + sampleRate, + duration, + freqScale, + colorScheme, + fileName, + shouldCancel, + }); } else { ctx.fillStyle = "#000000"; diff --git a/frontend/src/components/TitleBar.tsx b/frontend/src/components/TitleBar.tsx index 800330d..5929992 100644 --- a/frontend/src/components/TitleBar.tsx +++ b/frontend/src/components/TitleBar.tsx @@ -1,8 +1,9 @@ -import { X, Minus, Maximize, SlidersHorizontal, Info } from "lucide-react"; +import { X, Minus, Maximize, SlidersHorizontal, Info, Globe } from "lucide-react"; import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime"; import { Menubar, MenubarContent, MenubarMenu, MenubarItem, MenubarTrigger, MenubarLabel, MenubarSeparator } from "@/components/ui/menubar"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { getSettings, updateSettings } from "@/lib/settings"; +import { openExternal } from "@/lib/utils"; import { useState, useEffect } from "react"; export function TitleBar() { const [useSpotFetchAPI, setUseSpotFetchAPI] = useState(false); @@ -65,6 +66,11 @@ export function TitleBar() { Use SpotFetch API {useSpotFetchAPI ? "✓" : ""} + + openExternal("https://afkarxyz.qzz.io")} className="gap-2"> + + Website + diff --git a/frontend/src/components/TrackInfo.tsx b/frontend/src/components/TrackInfo.tsx index 132f515..eac6d31 100644 --- a/frontend/src/components/TrackInfo.tsx +++ b/frontend/src/components/TrackInfo.tsx @@ -4,7 +4,7 @@ import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import type { TrackMetadata, TrackAvailability } from "@/types/api"; -import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons"; +import { TidalAvailabilityIcon, QobuzAvailabilityIcon, AmazonAvailabilityIcon } from "./PlatformIcons"; import { usePreview } from "@/hooks/usePreview"; interface TrackInfoProps { track: TrackMetadata & { @@ -140,16 +140,22 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded {availability ? (
- - - + + +
) : (

Check Availability

)}
)} - {isDownloaded && ()} + {isDownloaded && ( + + + + +

Open Folder

+
+
)}
)}
diff --git a/frontend/src/components/TrackList.tsx b/frontend/src/components/TrackList.tsx index 5eab214..3fefc06 100644 --- a/frontend/src/components/TrackList.tsx +++ b/frontend/src/components/TrackList.tsx @@ -5,7 +5,7 @@ import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination"; import type { TrackMetadata, TrackAvailability } from "@/types/api"; -import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons"; +import { TidalAvailabilityIcon, QobuzAvailabilityIcon, AmazonAvailabilityIcon } from "./PlatformIcons"; import { usePreview } from "@/hooks/usePreview"; interface TrackListProps { tracks: TrackMetadata[]; @@ -328,9 +328,9 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa {availabilityMap?.has(track.spotify_id) ? (
- - - + + +
) : (

Check Availability

)}
)} diff --git a/frontend/src/hooks/useApiStatus.ts b/frontend/src/hooks/useApiStatus.ts new file mode 100644 index 0000000..589a730 --- /dev/null +++ b/frontend/src/hooks/useApiStatus.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from "react"; +import { API_SOURCES, checkAllApiStatuses, ensureApiStatusCheckStarted, getApiStatusState, subscribeApiStatus, } from "@/lib/api-status"; +export function useApiStatus() { + const [state, setState] = useState(getApiStatusState); + useEffect(() => { + ensureApiStatusCheckStarted(); + return subscribeApiStatus(() => { + setState(getApiStatusState()); + }); + }, []); + return { + ...state, + sources: API_SOURCES, + refreshAll: checkAllApiStatuses, + }; +} diff --git a/frontend/src/hooks/useAudioAnalysis.ts b/frontend/src/hooks/useAudioAnalysis.ts index fd4838c..0689e6e 100644 --- a/frontend/src/hooks/useAudioAnalysis.ts +++ b/frontend/src/hooks/useAudioAnalysis.ts @@ -2,7 +2,7 @@ import { useState, useCallback, useRef, useEffect, type MutableRefObject } from import type { AnalysisResult } from "@/types/api"; import { logger } from "@/lib/logger"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; -import { analyzeAudioArrayBuffer, analyzeAudioFile, analyzeSpectrumFromSamples, type AnalysisProgress, } from "@/lib/flac-analysis"; +import { analyzeAudioArrayBuffer, analyzeAudioFile, analyzeDecodedSamples, analyzeSpectrumFromSamples, parseAudioMetadataFromInput, pcm16MonoArrayBufferToFloat32Samples, type AnalysisProgress, type FrontendAnalysisPayload, type ParsedAudioMetadata, } from "@/lib/flac-analysis"; import { loadAudioAnalysisPreferences } from "@/lib/audio-analysis-preferences"; type WindowFunction = "hann" | "hamming" | "blackman" | "rectangular"; function toWindowFunction(value: string): WindowFunction { @@ -49,6 +49,8 @@ let sessionResult: AnalysisResult | null = null; let sessionSelectedFilePath = ""; let sessionError: string | null = null; let sessionSamples: Float32Array | null = null; +let sessionCurrentAnalysisKey = ""; +const sessionSamplesByKey = new Map(); interface ProgressState { percent: number; message: string; @@ -60,6 +62,35 @@ const DEFAULT_PROGRESS_STATE: ProgressState = { interface CancelToken { cancelled: boolean; } +interface AnalyzeExecutionOptions { + analysisKey?: string; + displayPath?: string; + suppressToast?: boolean; +} +export interface AnalyzeExecutionOutcome { + result: AnalysisResult | null; + error: string | null; + cancelled: boolean; +} +interface WailsWindow extends Window { + go?: { + main?: { + App?: { + ReadFileAsBase64?: (path: string) => Promise; + DecodeAudioForAnalysis?: (path: string) => Promise; + }; + }; + }; +} +interface BackendAnalysisDecodeResponse { + pcm_base64: string; + sample_rate: number; + channels: number; + bits_per_sample: number; + duration: number; + bitrate_kbps?: number; + bit_depth?: string; +} function cancelToken(tokenRef: MutableRefObject): void { if (tokenRef.current) { tokenRef.current.cancelled = true; @@ -81,6 +112,23 @@ function toProgressState(progress: AnalysisProgress): ProgressState { message: progress.message, }; } +function isDecodeFailure(error: unknown): boolean { + return error instanceof Error && /decode/i.test(error.message); +} +function mergeBackendDecodedMetadata(parsed: ParsedAudioMetadata, decoded: BackendAnalysisDecodeResponse): ParsedAudioMetadata { + const sampleRate = decoded.sample_rate > 0 ? decoded.sample_rate : parsed.sampleRate; + const bitsPerSample = decoded.bits_per_sample > 0 ? decoded.bits_per_sample : parsed.bitsPerSample; + const duration = decoded.duration > 0 ? decoded.duration : parsed.duration; + return { + ...parsed, + sampleRate, + channels: decoded.channels > 0 ? decoded.channels : parsed.channels, + bitsPerSample, + totalSamples: duration > 0 && sampleRate > 0 ? Math.floor(duration * sampleRate) : parsed.totalSamples, + duration, + bitrateKbps: decoded.bitrate_kbps ?? parsed.bitrateKbps, + }; +} export function useAudioAnalysis() { const [analyzing, setAnalyzing] = useState(false); const [analysisProgress, setAnalysisProgress] = useState(DEFAULT_PROGRESS_STATE); @@ -90,6 +138,7 @@ export function useAudioAnalysis() { const [spectrumLoading, setSpectrumLoading] = useState(false); const [spectrumProgress, setSpectrumProgress] = useState(DEFAULT_PROGRESS_STATE); const samplesRef = useRef(sessionSamples); + const currentAnalysisKeyRef = useRef(sessionCurrentAnalysisKey); const analysisTokenRef = useRef(null); const spectrumTokenRef = useRef(null); useEffect(() => { @@ -110,12 +159,32 @@ export function useAudioAnalysis() { sessionError = next; setError(next); }, []); - const analyzeFile = useCallback(async (file: File) => { + const setCurrentAnalysisKey = useCallback((analysisKey: string) => { + currentAnalysisKeyRef.current = analysisKey; + sessionCurrentAnalysisKey = analysisKey; + }, []); + const storeSuccessfulAnalysis = useCallback((analysisKey: string, displayPath: string, payload: FrontendAnalysisPayload) => { + sessionSamplesByKey.set(analysisKey, payload.samples); + samplesRef.current = payload.samples; + sessionSamples = payload.samples; + setCurrentAnalysisKey(analysisKey); + setResultWithSession(payload.result); + setSelectedFilePathWithSession(displayPath); + setErrorWithSession(null); + }, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]); + const analyzeFile = useCallback(async (file: File, options?: AnalyzeExecutionOptions): Promise => { if (!file) { - setErrorWithSession("No file provided"); - return null; + const errorMessage = "No file provided"; + setErrorWithSession(errorMessage); + return { + result: null, + error: errorMessage, + cancelled: false, + }; } const token = createToken(analysisTokenRef); + const analysisKey = options?.analysisKey || file.name; + const displayPath = options?.displayPath || file.name; cancelToken(spectrumTokenRef); setAnalyzing(true); setAnalysisProgress({ @@ -124,32 +193,44 @@ export function useAudioAnalysis() { }); setErrorWithSession(null); setResultWithSession(null); - setSelectedFilePathWithSession(file.name); + setSelectedFilePathWithSession(displayPath); + setCurrentAnalysisKey(analysisKey); try { - logger.info(`Analyzing audio file (frontend): ${file.name}`); + logger.info(`Analyzing audio file (frontend): ${displayPath}`); const start = Date.now(); const prefs = loadAudioAnalysisPreferences(); const payload = await analyzeAudioFile(file, { fftSize: prefs.fftSize, windowFunction: prefs.windowFunction, }, (progress) => { - if (token.cancelled) + if (token.cancelled) { return; + } setAnalysisProgress(toProgressState(progress)); }, () => token.cancelled); if (token.cancelled) { - return null; + return { + result: null, + error: null, + cancelled: true, + }; } - samplesRef.current = payload.samples; - sessionSamples = payload.samples; - setResultWithSession(payload.result); + storeSuccessfulAnalysis(analysisKey, displayPath, payload); const elapsed = ((Date.now() - start) / 1000).toFixed(2); logger.success(`Audio analysis completed in ${elapsed}s`); - return payload.result; + return { + result: payload.result, + error: null, + cancelled: false, + }; } catch (err) { if (isCancelledError(err)) { - return null; + return { + result: null, + error: null, + cancelled: true, + }; } const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file"; logger.error(`Analysis error: ${errorMessage}`); @@ -158,10 +239,16 @@ export function useAudioAnalysis() { percent: 0, message: "Analysis failed", }); - toast.error("Audio Analysis Failed", { - description: errorMessage, - }); - return null; + if (!options?.suppressToast) { + toast.error("Audio Analysis Failed", { + description: errorMessage, + }); + } + return { + result: null, + error: errorMessage, + cancelled: false, + }; } finally { if (analysisTokenRef.current === token) { @@ -169,13 +256,20 @@ export function useAudioAnalysis() { setAnalyzing(false); } } - }, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]); - const analyzeFilePath = useCallback(async (filePath: string) => { + }, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession, storeSuccessfulAnalysis]); + const analyzeFilePath = useCallback(async (filePath: string, options?: AnalyzeExecutionOptions): Promise => { if (!filePath) { - setErrorWithSession("No file path provided"); - return null; + const errorMessage = "No file path provided"; + setErrorWithSession(errorMessage); + return { + result: null, + error: errorMessage, + cancelled: false, + }; } const token = createToken(analysisTokenRef); + const analysisKey = options?.analysisKey || filePath; + const displayPath = options?.displayPath || filePath; cancelToken(spectrumTokenRef); setAnalyzing(true); setAnalysisProgress({ @@ -184,18 +278,23 @@ export function useAudioAnalysis() { }); setErrorWithSession(null); setResultWithSession(null); - setSelectedFilePathWithSession(filePath); + setSelectedFilePathWithSession(displayPath); + setCurrentAnalysisKey(analysisKey); try { logger.info(`Analyzing audio file (frontend from path): ${filePath}`); const start = Date.now(); const prefs = loadAudioAnalysisPreferences(); - const readFileAsBase64 = (window as any)?.go?.main?.App?.ReadFileAsBase64 as ((path: string) => Promise) | undefined; + const readFileAsBase64 = (window as WailsWindow).go?.main?.App?.ReadFileAsBase64; if (!readFileAsBase64) { throw new Error("ReadFileAsBase64 backend method is unavailable"); } let base64Data = await readFileAsBase64(filePath); if (token.cancelled) { - return null; + return { + result: null, + error: null, + cancelled: true, + }; } setAnalysisProgress({ percent: 10, @@ -204,42 +303,105 @@ export function useAudioAnalysis() { const arrayBuffer = await base64ToArrayBuffer(base64Data, () => token.cancelled); base64Data = ""; if (token.cancelled) { - return null; + return { + result: null, + error: null, + cancelled: true, + }; } setAnalysisProgress({ percent: 15, message: "Preparing audio buffer...", }); const fileName = fileNameFromPath(filePath); - const payload = await analyzeAudioArrayBuffer({ + const input = { fileName, fileSize: arrayBuffer.byteLength, arrayBuffer, - }, { + }; + const analysisParams = { fftSize: prefs.fftSize, windowFunction: prefs.windowFunction, - }, (progress) => { - if (token.cancelled) + } as const; + const updateProgress = (progress: AnalysisProgress) => { + if (token.cancelled) { return; + } const mappedPercent = 10 + (progress.percent * 0.9); setAnalysisProgress({ percent: Math.round(Math.max(0, Math.min(100, mappedPercent))), message: progress.message, }); - }, () => token.cancelled); - if (token.cancelled) { - return null; + }; + let payload: FrontendAnalysisPayload; + try { + payload = await analyzeAudioArrayBuffer(input, analysisParams, updateProgress, () => token.cancelled); } - samplesRef.current = payload.samples; - sessionSamples = payload.samples; - setResultWithSession(payload.result); + catch (err) { + if (!isDecodeFailure(err)) { + throw err; + } + const decodeAudioForAnalysis = (window as WailsWindow).go?.main?.App?.DecodeAudioForAnalysis; + if (!decodeAudioForAnalysis) { + throw err; + } + logger.warning(`Browser decoder failed for ${fileName}; trying FFmpeg fallback`); + setAnalysisProgress({ + percent: 18, + message: "Browser decoder failed, trying FFmpeg fallback...", + }); + const decoded = await decodeAudioForAnalysis(filePath); + if (token.cancelled) { + return { + result: null, + error: null, + cancelled: true, + }; + } + setAnalysisProgress({ + percent: 24, + message: "Decoding audio with FFmpeg...", + }); + const pcmBase64 = decoded.pcm_base64 || ""; + if (!pcmBase64) { + throw new Error("FFmpeg analysis decode returned no PCM data"); + } + const pcmBuffer = await base64ToArrayBuffer(pcmBase64, () => token.cancelled); + if (token.cancelled) { + return { + result: null, + error: null, + cancelled: true, + }; + } + const parsedMetadata = parseAudioMetadataFromInput(input); + const mergedMetadata = mergeBackendDecodedMetadata(parsedMetadata, decoded); + const samples = pcm16MonoArrayBufferToFloat32Samples(pcmBuffer); + payload = await analyzeDecodedSamples(input, mergedMetadata, samples, analysisParams, updateProgress, () => token.cancelled, mergedMetadata.duration); + } + if (token.cancelled) { + return { + result: null, + error: null, + cancelled: true, + }; + } + storeSuccessfulAnalysis(analysisKey, displayPath, payload); const elapsed = ((Date.now() - start) / 1000).toFixed(2); logger.success(`Audio analysis completed in ${elapsed}s`); - return payload.result; + return { + result: payload.result, + error: null, + cancelled: false, + }; } catch (err) { if (isCancelledError(err)) { - return null; + return { + result: null, + error: null, + cancelled: true, + }; } const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file"; logger.error(`Analysis error: ${errorMessage}`); @@ -248,10 +410,16 @@ export function useAudioAnalysis() { percent: 0, message: "Analysis failed", }); - toast.error("Audio Analysis Failed", { - description: errorMessage, - }); - return null; + if (!options?.suppressToast) { + toast.error("Audio Analysis Failed", { + description: errorMessage, + }); + } + return { + result: null, + error: errorMessage, + cancelled: false, + }; } finally { if (analysisTokenRef.current === token) { @@ -259,10 +427,46 @@ export function useAudioAnalysis() { setAnalyzing(false); } } - }, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]); - const reAnalyzeSpectrum = useCallback(async (fftSize: number, windowFunction: string) => { - if (!result || !samplesRef.current) + }, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession, storeSuccessfulAnalysis]); + const loadStoredAnalysis = useCallback((analysisKey: string, nextResult: AnalysisResult, displayPath: string) => { + setCurrentAnalysisKey(analysisKey); + samplesRef.current = sessionSamplesByKey.get(analysisKey) ?? null; + sessionSamples = samplesRef.current; + setResultWithSession(nextResult); + setSelectedFilePathWithSession(displayPath); + setErrorWithSession(null); + }, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]); + const clearStoredAnalysis = useCallback((analysisKey?: string) => { + if (analysisKey) { + sessionSamplesByKey.delete(analysisKey); + if (currentAnalysisKeyRef.current === analysisKey) { + currentAnalysisKeyRef.current = ""; + sessionCurrentAnalysisKey = ""; + samplesRef.current = null; + sessionSamples = null; + } return; + } + sessionSamplesByKey.clear(); + currentAnalysisKeyRef.current = ""; + sessionCurrentAnalysisKey = ""; + samplesRef.current = null; + sessionSamples = null; + }, []); + const cancelAnalysis = useCallback(() => { + cancelToken(analysisTokenRef); + setAnalyzing(false); + setAnalysisProgress((prev) => prev.percent > 0 + ? { + percent: prev.percent, + message: "Analysis stopped", + } + : DEFAULT_PROGRESS_STATE); + }, []); + const reAnalyzeSpectrum = useCallback(async (fftSize: number, windowFunction: string) => { + if (!result || !samplesRef.current) { + return null; + } const token = createToken(spectrumTokenRef); setSpectrumLoading(true); setSpectrumProgress({ @@ -275,22 +479,24 @@ export function useAudioAnalysis() { fftSize, windowFunction: toWindowFunction(windowFunction), }, (progress) => { - if (token.cancelled) + if (token.cancelled) { return; + } setSpectrumProgress(toProgressState(progress)); }, () => token.cancelled); if (token.cancelled) { - return; + return null; } - setResult((prev) => { - const next = prev ? { ...prev, spectrum } : prev; - sessionResult = next; - return next; - }); + const nextResult = { + ...result, + spectrum, + }; + setResultWithSession(nextResult); + return nextResult; } catch (err) { if (isCancelledError(err)) { - return; + return null; } const errorMessage = err instanceof Error ? err.message : "Failed to re-analyze spectrum"; logger.error(`Spectrum re-analysis error: ${errorMessage}`); @@ -301,6 +507,7 @@ export function useAudioAnalysis() { toast.error("Spectrum Analysis Failed", { description: errorMessage, }); + return null; } finally { if (spectrumTokenRef.current === token) { @@ -308,7 +515,7 @@ export function useAudioAnalysis() { setSpectrumLoading(false); } } - }, [result]); + }, [result, setResultWithSession]); const clearResult = useCallback(() => { cancelToken(analysisTokenRef); cancelToken(spectrumTokenRef); @@ -319,6 +526,8 @@ export function useAudioAnalysis() { setSpectrumLoading(false); setAnalysisProgress(DEFAULT_PROGRESS_STATE); setSpectrumProgress(DEFAULT_PROGRESS_STATE); + currentAnalysisKeyRef.current = ""; + sessionCurrentAnalysisKey = ""; samplesRef.current = null; sessionSamples = null; }, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]); @@ -332,6 +541,9 @@ export function useAudioAnalysis() { spectrumProgress, analyzeFile, analyzeFilePath, + cancelAnalysis, + loadStoredAnalysis, + clearStoredAnalysis, reAnalyzeSpectrum, clearResult, }; diff --git a/frontend/src/hooks/useAvailability.ts b/frontend/src/hooks/useAvailability.ts index efb7dd8..c835705 100644 --- a/frontend/src/hooks/useAvailability.ts +++ b/frontend/src/hooks/useAvailability.ts @@ -2,6 +2,7 @@ import { useState, useCallback } from "react"; import { CheckTrackAvailability } from "../../wailsjs/go/main/App"; import type { TrackAvailability } from "@/types/api"; import { logger } from "@/lib/logger"; +import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout"; export function useAvailability() { const [checking, setChecking] = useState(false); const [checkingTrackId, setCheckingTrackId] = useState(null); @@ -20,7 +21,7 @@ export function useAvailability() { setError(null); try { logger.info(`Checking availability for track: ${spotifyId}`); - const response = await CheckTrackAvailability(spotifyId); + const response = await withTimeout(CheckTrackAvailability(spotifyId), CHECK_TIMEOUT_MS, `Availability check timed out after 10 seconds for ${spotifyId}`); const availability: TrackAvailability = JSON.parse(response); setAvailabilityMap((prev) => { const newMap = new Map(prev); diff --git a/frontend/src/lib/api-status.ts b/frontend/src/lib/api-status.ts new file mode 100644 index 0000000..379e886 --- /dev/null +++ b/frontend/src/lib/api-status.ts @@ -0,0 +1,111 @@ +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 { + id: string; + type: string; + name: string; + url: string; +} +export const API_SOURCES: ApiSource[] = [ + { id: "tidal1", type: "tidal", name: "Tidal A", url: "https://hifi-one.spotisaver.net" }, + { id: "tidal2", type: "tidal", name: "Tidal B", url: "https://hifi-two.spotisaver.net" }, + { id: "tidal3", type: "tidal", name: "Tidal C", url: "https://eu-central.monochrome.tf" }, + { id: "tidal4", type: "tidal", name: "Tidal D", url: "https://us-west.monochrome.tf" }, + { id: "tidal5", type: "tidal", name: "Tidal E", url: "https://api.monochrome.tf" }, + { id: "tidal6", type: "tidal", name: "Tidal F", url: "https://monochrome-api.samidy.com" }, + { id: "tidal7", type: "tidal", name: "Tidal G", url: "https://tidal.kinoplus.online" }, + { id: "qobuz1", type: "qobuz", name: "Qobuz A", url: "https://dab.yeet.su" }, + { id: "qobuz2", type: "qobuz", name: "Qobuz B", url: "https://dabmusic.xyz" }, + { id: "qobuz3", type: "qbz", name: "Qobuz C", url: "https://qbz.afkarxyz.qzz.io" }, + { id: "amazon1", type: "amazon", name: "Amazon Music", url: "https://amzn.afkarxyz.qzz.io" }, +]; +type ApiStatusState = { + isCheckingAll: boolean; + statuses: Record; +}; +let apiStatusState: ApiStatusState = { + isCheckingAll: false, + statuses: {}, +}; +let activeCheckAll: Promise | null = null; +const listeners = new Set<() => void>(); +function emitApiStatusChange() { + for (const listener of listeners) { + listener(); + } +} +function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState) { + apiStatusState = updater(apiStatusState); + emitApiStatusChange(); +} +async function checkSingleApiStatus(source: ApiSource): Promise { + setApiStatusState((current) => ({ + ...current, + statuses: { + ...current.statuses, + [source.id]: "checking", + }, + })); + try { + const isOnline = await withTimeout(CheckAPIStatus(source.type, source.url), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.url}`); + setApiStatusState((current) => ({ + ...current, + statuses: { + ...current.statuses, + [source.id]: isOnline ? "online" : "offline", + }, + })); + } + catch { + setApiStatusState((current) => ({ + ...current, + statuses: { + ...current.statuses, + [source.id]: "offline", + }, + })); + } +} +export function getApiStatusState(): ApiStatusState { + return apiStatusState; +} +export function subscribeApiStatus(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} +export function hasApiStatusResults(): boolean { + return API_SOURCES.some((source) => { + const status = apiStatusState.statuses[source.id]; + return status === "online" || status === "offline"; + }); +} +export function ensureApiStatusCheckStarted(): void { + if (!activeCheckAll && !hasApiStatusResults()) { + void checkAllApiStatuses(); + } +} +export async function checkAllApiStatuses(): Promise { + if (activeCheckAll) { + return activeCheckAll; + } + activeCheckAll = (async () => { + setApiStatusState((current) => ({ + ...current, + isCheckingAll: true, + })); + try { + await Promise.allSettled(API_SOURCES.map((source) => checkSingleApiStatus(source))); + } + finally { + setApiStatusState((current) => ({ + ...current, + isCheckingAll: false, + })); + activeCheckAll = null; + } + })(); + return activeCheckAll; +} diff --git a/frontend/src/lib/async-timeout.ts b/frontend/src/lib/async-timeout.ts new file mode 100644 index 0000000..5c90ab4 --- /dev/null +++ b/frontend/src/lib/async-timeout.ts @@ -0,0 +1,17 @@ +export const CHECK_TIMEOUT_MS = 10 * 1000; +export function withTimeout(promise: Promise, timeoutMs: number = CHECK_TIMEOUT_MS, message: string = `Operation timed out after ${Math.round(timeoutMs / 1000)} seconds`): Promise { + return new Promise((resolve, reject) => { + const timer = window.setTimeout(() => { + reject(new Error(message)); + }, timeoutMs); + promise + .then((value) => { + window.clearTimeout(timer); + resolve(value); + }) + .catch((error) => { + window.clearTimeout(timer); + reject(error); + }); + }); +} diff --git a/frontend/src/lib/flac-analysis.ts b/frontend/src/lib/flac-analysis.ts index d3bc39c..9730203 100644 --- a/frontend/src/lib/flac-analysis.ts +++ b/frontend/src/lib/flac-analysis.ts @@ -17,8 +17,8 @@ const MP4_CONTAINER_TYPES = new Set([ "moov", "trak", "mdia", "minf", "stbl", "edts", "dinf", "udta", "ilst", "meta", "stsd", "wave", ]); -type SupportedAudioFileType = "FLAC" | "MP3" | "M4A" | "AAC"; -interface ParsedAudioMetadata { +export type SupportedAudioFileType = "FLAC" | "MP3" | "M4A" | "AAC"; +export interface ParsedAudioMetadata { fileType: SupportedAudioFileType; sampleRate: number; channels: number; @@ -417,7 +417,7 @@ function parseM4aMetadata(buffer: ArrayBuffer): ParsedAudioMetadata { } } } - else if ((box.type === "mp4a" || box.type === "aac ") && box.offset + 36 <= boxEnd) { + else if ((box.type === "mp4a" || box.type === "aac " || box.type === "alac") && box.offset + 36 <= boxEnd) { channels = view.getUint16(box.offset + 24, false) || channels; bitsPerSample = view.getUint16(box.offset + 26, false) || bitsPerSample; if (!sampleRate) { @@ -455,7 +455,7 @@ function parseM4aMetadata(buffer: ArrayBuffer): ParsedAudioMetadata { duration, }; } -function parseAudioMetadata(input: AudioArrayBufferInput): ParsedAudioMetadata { +export function parseAudioMetadataFromInput(input: AudioArrayBufferInput): ParsedAudioMetadata { const fileType = detectAudioFileType(input.arrayBuffer, input.fileName); switch (fileType) { case "FLAC": return parseFlacMetadata(input.arrayBuffer); @@ -465,6 +465,15 @@ function parseAudioMetadata(input: AudioArrayBufferInput): ParsedAudioMetadata { default: throw new Error(`Unsupported audio format: ${input.fileName || "unknown"}`); } } +export function pcm16MonoArrayBufferToFloat32Samples(buffer: ArrayBuffer): Float32Array { + const sampleCount = Math.floor(buffer.byteLength / 2); + const samples = new Float32Array(sampleCount); + const view = new DataView(buffer); + for (let i = 0; i < sampleCount; i++) { + samples[i] = view.getInt16(i * 2, true) / 32768; + } + return samples; +} function buildWindowCoefficients(size: number, windowFunction: SpectrumParams["windowFunction"]): Float32Array { const coeffs = new Float32Array(size); if (size <= 1) { @@ -649,7 +658,7 @@ export async function analyzeAudioFile(file: File, params: SpectrumParams = DEFA export async function analyzeAudioArrayBuffer(input: AudioArrayBufferInput, params: SpectrumParams = DEFAULT_PARAMS, onProgress?: AnalysisProgressCallback, shouldCancel?: AnalysisCancelCheck): Promise { throwIfCancelled(shouldCancel); reportProgress(onProgress, "parse", 5, "Parsing audio metadata..."); - const metadata = parseAudioMetadata(input); + const metadata = parseAudioMetadataFromInput(input); throwIfCancelled(shouldCancel); reportProgress(onProgress, "decode", 15, "Decoding audio stream..."); const audioContext = createAnalysisAudioContext(metadata.sampleRate); @@ -658,70 +667,81 @@ export async function analyzeAudioArrayBuffer(input: AudioArrayBufferInput, para throwIfCancelled(shouldCancel); reportProgress(onProgress, "decode", 35, "Audio decoded"); const samples = audioBuffer.getChannelData(0); - reportProgress(onProgress, "metrics", 40, "Calculating peak/RMS..."); - let peak = 0; - let sumSquares = 0; - let lastMetricsYieldAt = nowMs(); - for (let i = 0; i < samples.length; i++) { - throwIfCancelled(shouldCancel); - const sample = samples[i]; - const absSample = Math.abs(sample); - if (absSample > peak) - peak = absSample; - sumSquares += sample * sample; - if ((i + 1) % METRICS_CHUNK_SIZE === 0 || i === samples.length - 1) { - const metricsProgress = 40 + (((i + 1) / samples.length) * 10); - reportProgress(onProgress, "metrics", metricsProgress, "Calculating peak/RMS..."); - const now = nowMs(); - if (now - lastMetricsYieldAt >= 16) { - await nextTick(); - lastMetricsYieldAt = nowMs(); - throwIfCancelled(shouldCancel); - } - } - } - const peakDB = peak > 0 ? 20 * Math.log10(peak) : -120; - const rms = samples.length > 0 ? Math.sqrt(sumSquares / samples.length) : 0; - const rmsDB = rms > 0 ? 20 * Math.log10(rms) : -120; - const dynamicRange = peakDB - rmsDB; - const duration = audioBuffer.duration > 0 ? audioBuffer.duration : metadata.duration; - const totalSamples = metadata.totalSamples > 0 - ? metadata.totalSamples - : Math.floor(duration * metadata.sampleRate); - reportProgress(onProgress, "metrics", 50, "Signal metrics complete"); - const spectrum = await analyzeSpectrumFromSamples(samples, metadata.sampleRate, params, (progress) => { - const mappedPercent = 50 + (progress.percent * 0.45); - reportProgress(onProgress, "spectrum", mappedPercent, progress.message); - }, shouldCancel); - reportProgress(onProgress, "finalize", 97, "Finalizing result..."); - const payload: FrontendAnalysisPayload = { - result: { - file_path: input.fileName, - file_size: input.fileSize, - file_type: metadata.fileType, - sample_rate: metadata.sampleRate, - channels: metadata.channels || audioBuffer.numberOfChannels, - bits_per_sample: metadata.bitsPerSample, - total_samples: totalSamples, - duration, - bit_depth: `${metadata.bitsPerSample}-bit`, - dynamic_range: dynamicRange, - peak_amplitude: peakDB, - rms_level: rmsDB, - codec_mode: metadata.codecMode, - bitrate_kbps: metadata.bitrateKbps, - total_frames: metadata.totalFrames, - codec_version: metadata.codecVersion, - spectrum, - }, - samples, - }; - reportProgress(onProgress, "finalize", 100, "Analysis complete"); - return payload; + return analyzeDecodedSamples(input, metadata, samples, params, onProgress, shouldCancel, audioBuffer.duration); } finally { await audioContext.close(); } } +export async function analyzeDecodedSamples(input: AudioArrayBufferInput, metadata: ParsedAudioMetadata, samples: Float32Array, params: SpectrumParams = DEFAULT_PARAMS, onProgress?: AnalysisProgressCallback, shouldCancel?: AnalysisCancelCheck, durationOverride?: number): Promise { + throwIfCancelled(shouldCancel); + const analysisSampleRate = metadata.sampleRate > 0 ? metadata.sampleRate : 44100; + const analysisChannels = metadata.channels > 0 ? metadata.channels : 1; + const bitDepthLabel = metadata.bitsPerSample > 0 ? `${metadata.bitsPerSample}-bit` : "Unknown"; + reportProgress(onProgress, "metrics", 40, "Calculating peak/RMS..."); + let peak = 0; + let sumSquares = 0; + let lastMetricsYieldAt = nowMs(); + for (let i = 0; i < samples.length; i++) { + throwIfCancelled(shouldCancel); + const sample = samples[i]; + const absSample = Math.abs(sample); + if (absSample > peak) + peak = absSample; + sumSquares += sample * sample; + if ((i + 1) % METRICS_CHUNK_SIZE === 0 || i === samples.length - 1) { + const metricsProgress = 40 + (((i + 1) / Math.max(1, samples.length)) * 10); + reportProgress(onProgress, "metrics", metricsProgress, "Calculating peak/RMS..."); + const now = nowMs(); + if (now - lastMetricsYieldAt >= 16) { + await nextTick(); + lastMetricsYieldAt = nowMs(); + throwIfCancelled(shouldCancel); + } + } + } + const peakDB = peak > 0 ? 20 * Math.log10(peak) : -120; + const rms = samples.length > 0 ? Math.sqrt(sumSquares / samples.length) : 0; + const rmsDB = rms > 0 ? 20 * Math.log10(rms) : -120; + const dynamicRange = peakDB - rmsDB; + const duration = durationOverride && durationOverride > 0 + ? durationOverride + : (metadata.duration > 0 + ? metadata.duration + : (analysisSampleRate > 0 ? samples.length / analysisSampleRate : 0)); + const totalSamples = metadata.totalSamples > 0 + ? metadata.totalSamples + : (duration > 0 ? Math.floor(duration * analysisSampleRate) : samples.length); + reportProgress(onProgress, "metrics", 50, "Signal metrics complete"); + const spectrum = await analyzeSpectrumFromSamples(samples, analysisSampleRate, params, (progress) => { + const mappedPercent = 50 + (progress.percent * 0.45); + reportProgress(onProgress, "spectrum", mappedPercent, progress.message); + }, shouldCancel); + reportProgress(onProgress, "finalize", 97, "Finalizing result..."); + const payload: FrontendAnalysisPayload = { + result: { + file_path: input.fileName, + file_size: input.fileSize, + file_type: metadata.fileType, + sample_rate: analysisSampleRate, + channels: analysisChannels, + bits_per_sample: metadata.bitsPerSample, + total_samples: totalSamples, + duration, + bit_depth: bitDepthLabel, + dynamic_range: dynamicRange, + peak_amplitude: peakDB, + rms_level: rmsDB, + codec_mode: metadata.codecMode, + bitrate_kbps: metadata.bitrateKbps, + total_frames: metadata.totalFrames, + codec_version: metadata.codecVersion, + spectrum, + }, + samples, + }; + reportProgress(onProgress, "finalize", 100, "Analysis complete"); + return payload; +} export const analyzeFlacFile = analyzeAudioFile; export const analyzeFlacArrayBuffer = analyzeAudioArrayBuffer; diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index 0920b4e..a1e1259 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -5,6 +5,8 @@ export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track- export interface Settings { downloadPath: string; downloader: "auto" | "tidal" | "qobuz" | "amazon"; + linkResolver: "songstats" | "songlink"; + allowResolverFallback: boolean; theme: string; themeMode: "auto" | "light" | "dark"; fontFamily: FontFamily; @@ -93,6 +95,8 @@ function detectOS(): "Windows" | "linux/MacOS" { export const DEFAULT_SETTINGS: Settings = { downloadPath: "", downloader: "auto", + linkResolver: "songlink", + allowResolverFallback: true, theme: "yellow", themeMode: "auto", fontFamily: "google-sans", @@ -225,6 +229,12 @@ function getSettingsFromLocalStorage(): Settings { if (!('allowFallback' in parsed)) { parsed.allowFallback = true; } + if (!('linkResolver' in parsed)) { + parsed.linkResolver = "songlink"; + } + if (!('allowResolverFallback' in parsed)) { + parsed.allowResolverFallback = true; + } if (!('separator' in parsed)) { parsed.separator = "semicolon"; } @@ -304,6 +314,12 @@ export async function loadSettings(): Promise { 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; } diff --git a/wails.json b/wails.json index 3ea5222..a6b85a0 100644 --- a/wails.json +++ b/wails.json @@ -12,7 +12,7 @@ }, "info": { "productName": "SpotiFLAC", - "productVersion": "7.1.2", + "productVersion": "7.1.3", "copyright": "© 2026 afkarxyz" }, "wailsjsdir": "./frontend",