diff --git a/app.go b/app.go index a488c70..4a8f023 100644 --- a/app.go +++ b/app.go @@ -106,6 +106,22 @@ type DownloadResponse struct { ItemID string `json:"item_id,omitempty"` } +func cleanupInvalidDownloadArtifacts(paths ...string) { + seen := make(map[string]struct{}, len(paths)) + for _, path := range paths { + if path == "" { + continue + } + if _, ok := seen[path]; ok { + continue + } + seen[path] = struct{}{} + if err := os.Remove(path); err == nil { + fmt.Printf("Removed invalid download artifact: %s\n", path) + } + } +} + func (a *App) GetStreamingURLs(spotifyTrackID string, region string) (string, error) { if spotifyTrackID == "" { return "", fmt.Errorf("spotify track ID is required") @@ -142,12 +158,27 @@ func (a *App) GetSpotifyMetadata(req SpotifyMetadataRequest) (string, error) { defer cancel() settings, err := a.LoadSettings() + separator := req.Separator + if separator == "" { + separator = ", " + if err == nil && settings != nil { + if sep, ok := settings["separator"].(string); ok { + if sep == "semicolon" { + separator = "; " + } else if sep == "comma" { + separator = ", " + } + } + } + } if err == nil && settings != nil { if useAPI, ok := settings["useSpotFetchAPI"].(bool); ok && useAPI { if apiURL, ok := settings["spotFetchAPIUrl"].(string); ok && apiURL != "" { - data, err := backend.GetSpotifyDataWithAPI(ctx, req.URL, true, apiURL, req.Batch, time.Duration(req.Delay*float64(time.Second))) + data, err := backend.GetSpotifyDataWithAPI(ctx, req.URL, true, apiURL, req.Batch, time.Duration(req.Delay*float64(time.Second)), separator, func(tracks interface{}) { + runtime.EventsEmit(a.ctx, "metadata-stream", tracks) + }) if err != nil { return "", fmt.Errorf("failed to fetch metadata from API: %v", err) } @@ -162,7 +193,9 @@ func (a *App) GetSpotifyMetadata(req SpotifyMetadataRequest) (string, error) { } } - data, err := backend.GetFilteredSpotifyData(ctx, req.URL, req.Batch, time.Duration(req.Delay*float64(time.Second))) + data, err := backend.GetFilteredSpotifyData(ctx, req.URL, req.Batch, time.Duration(req.Delay*float64(time.Second)), separator, func(tracks interface{}) { + runtime.EventsEmit(a.ctx, "metadata-stream", tracks) + }) if err != nil { return "", fmt.Errorf("failed to fetch metadata: %v", err) } @@ -283,7 +316,21 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { defer cancel() trackURL := fmt.Sprintf("https://open.spotify.com/track/%s", req.SpotifyID) - trackData, err := backend.GetFilteredSpotifyData(ctx, trackURL, false, 0) + metadataSeparator := req.Separator + if metadataSeparator == "" { + metadataSeparator = ", " + metadataSettings, _ := a.LoadSettings() + if metadataSettings != nil { + if sep, ok := metadataSettings["separator"].(string); ok { + if sep == "semicolon" { + metadataSeparator = "; " + } else if sep == "comma" { + metadataSeparator = ", " + } + } + } + } + trackData, err := backend.GetFilteredSpotifyData(ctx, trackURL, false, 0, metadataSeparator, nil) if err == nil { var trackResp struct { @@ -358,11 +405,15 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { close(lyricsChan) } - go func() { - client := backend.NewSongLinkClient() - isrc, _ := client.GetISRC(req.SpotifyID) - isrcChan <- isrc - }() + if req.Service == "qobuz" { + go func() { + client := backend.NewSongLinkClient() + isrc, _ := client.GetISRCDirect(req.SpotifyID) + isrcChan <- isrc + }() + } else { + close(isrcChan) + } } else { close(lyricsChan) close(isrcChan) @@ -439,6 +490,23 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { filename = strings.TrimPrefix(filename, "EXISTS:") } + if !alreadyExists { + validated, validationErr := backend.ValidateDownloadedTrackDuration(filename, req.Duration) + if validationErr != nil { + cleanupInvalidDownloadArtifacts(filename) + errorMessage := validationErr.Error() + backend.FailDownloadItem(itemID, errorMessage) + return DownloadResponse{ + Success: false, + Error: errorMessage, + ItemID: itemID, + }, fmt.Errorf(errorMessage) + } + if !validated { + fmt.Printf("[DownloadValidation] Skipped duration validation for %s (expected=%ds)\n", filename, req.Duration) + } + } + if !alreadyExists && req.SpotifyID != "" && req.EmbedLyrics && (strings.HasSuffix(filename, ".flac") || strings.HasSuffix(filename, ".mp3") || strings.HasSuffix(filename, ".m4a")) { fmt.Printf("\nWaiting for lyrics fetch to complete...\n") lyrics := <-lyricsChan @@ -470,6 +538,14 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { message = "File already exists" backend.SkipDownloadItem(itemID, filename) } else { + if strings.EqualFold(filepath.Ext(filename), ".flac") && req.CoverURL != "" { + coverClient := backend.NewCoverClient() + if iconErr := coverClient.ApplyMacOSFLACFileIcon(filename, req.CoverURL, 256, req.EmbedMaxQualityCover); iconErr != nil { + fmt.Printf("Warning: failed to set macOS FLAC file icon: %v\n", iconErr) + } else { + fmt.Printf("macOS FLAC file icon set: %s\n", filename) + } + } if fileInfo, statErr := os.Stat(filename); statErr == nil { finalSize := float64(fileInfo.Size()) / (1024 * 1024) @@ -479,15 +555,15 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { backend.CompleteDownloadItem(itemID, filename, 0) } - go func(fPath, track, artist, album, sID, cover, format string) { + go func(fPath, track, artist, album, sID, cover, format, source string) { + time.Sleep(2 * time.Second) + quality := "Unknown" - durationStr := "--:--" + durationStr := "0:00" meta, err := backend.GetTrackMetadata(fPath) - if err == nil && meta != nil { - if meta.BitsPerSample > 0 { - quality = fmt.Sprintf("%d-bit/%.1fkHz", meta.BitsPerSample, float64(meta.SampleRate)/1000.0) - } else if meta.Bitrate > 0 { + if err == nil { + if meta.Bitrate > 0 { quality = fmt.Sprintf("%dkbps/%.1fkHz", meta.Bitrate/1000, float64(meta.SampleRate)/1000.0) } else if meta.SampleRate > 0 { quality = fmt.Sprintf("%.1fkHz", float64(meta.SampleRate)/1000.0) @@ -508,6 +584,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { Quality: quality, Format: strings.ToUpper(format), Path: fPath, + Source: source, } if item.Format == "" || item.Format == "LOSSLESS" { @@ -523,7 +600,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { } backend.AddHistoryItem(item, "SpotiFLAC") - }(filename, req.TrackName, req.ArtistName, req.AlbumName, req.SpotifyID, req.CoverURL, req.AudioFormat) + }(filename, req.TrackName, req.ArtistName, req.AlbumName, req.SpotifyID, req.CoverURL, req.AudioFormat, req.Service) } return DownloadResponse{ @@ -755,46 +832,28 @@ func (a *App) ClearFetchHistoryByType(itemType string) error { return backend.ClearFetchHistoryByType(itemType, "SpotiFLAC") } -func (a *App) AnalyzeTrack(filePath string) (string, error) { - if filePath == "" { - return "", fmt.Errorf("file path is required") +func (a *App) SaveSpectrumImage(audioFilePath string, base64Data string) (string, error) { + if audioFilePath == "" || base64Data == "" { + return "", fmt.Errorf("file path and image data are required") } - result, err := backend.AnalyzeTrack(filePath) + base64Data = strings.TrimPrefix(base64Data, "data:image/png;base64,") + + data, err := base64.StdEncoding.DecodeString(base64Data) if err != nil { - return "", fmt.Errorf("failed to analyze track: %v", err) + return "", fmt.Errorf("failed to decode base64 image: %v", err) } - jsonData, err := json.Marshal(result) + ext := filepath.Ext(audioFilePath) + baseName := strings.TrimSuffix(filepath.Base(audioFilePath), ext) + outPath := filepath.Join(filepath.Dir(audioFilePath), baseName+".png") + + err = os.WriteFile(outPath, data, 0644) if err != nil { - return "", fmt.Errorf("failed to encode response: %v", err) + return "", fmt.Errorf("failed to save image to disk: %v", err) } - return string(jsonData), nil -} - -func (a *App) AnalyzeMultipleTracks(filePaths []string) (string, error) { - if len(filePaths) == 0 { - return "", fmt.Errorf("at least one file path is required") - } - - results := make([]*backend.AnalysisResult, 0, len(filePaths)) - - for _, filePath := range filePaths { - result, err := backend.AnalyzeTrack(filePath) - if err != nil { - - continue - } - results = append(results, result) - } - - jsonData, err := json.Marshal(results) - if err != nil { - return "", fmt.Errorf("failed to encode response: %v", err) - } - - return string(jsonData), nil + return outPath, nil } type LyricsDownloadRequest struct { @@ -1121,6 +1180,21 @@ func (a *App) ConvertAudio(req ConvertAudioRequest) ([]backend.ConvertAudioResul return backend.ConvertAudio(backendReq) } +type ResampleAudioRequest struct { + InputFiles []string `json:"input_files"` + SampleRate string `json:"sample_rate"` + BitDepth string `json:"bit_depth"` +} + +func (a *App) ResampleAudio(req ResampleAudioRequest) ([]backend.ResampleResult, error) { + backendReq := backend.ResampleRequest{ + InputFiles: req.InputFiles, + SampleRate: req.SampleRate, + BitDepth: req.BitDepth, + } + return backend.ResampleAudio(backendReq) +} + func (a *App) SelectAudioFiles() ([]string, error) { files, err := backend.SelectMultipleFiles(a.ctx) if err != nil { @@ -1129,6 +1203,10 @@ func (a *App) SelectAudioFiles() ([]string, error) { return files, nil } +func (a *App) GetFlacInfoBatch(paths []string) []backend.FlacInfo { + return backend.GetFlacInfoBatch(paths) +} + func (a *App) GetFileSizes(files []string) map[string]int64 { return backend.GetFileSizes(files) } @@ -1170,6 +1248,15 @@ func (a *App) ReadTextFile(filePath string) (string, error) { return string(content), nil } +func (a *App) ReadFileAsBase64(filePath string) (string, error) { + content, err := os.ReadFile(filePath) + if err != nil { + return "", err + } + + return base64.StdEncoding.EncodeToString(content), nil +} + 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 9acaaa6..47d9754 100644 --- a/backend/amazon.go +++ b/backend/amazon.go @@ -5,7 +5,6 @@ import ( "fmt" "io" "net/http" - "net/url" "os" "os/exec" "path/filepath" @@ -19,12 +18,6 @@ type AmazonDownloader struct { regions []string } -type SongLinkResponse struct { - LinksByPlatform map[string]struct { - URL string `json:"url"` - } `json:"linksByPlatform"` -} - type AmazonStreamResponse struct { StreamURL string `json:"streamUrl"` DecryptionKey string `json:"decryptionKey"` @@ -40,65 +33,17 @@ func NewAmazonDownloader() *AmazonDownloader { } func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) { - - spotifyBase := "https://open.spotify.com/track/" - spotifyURL := fmt.Sprintf("%s%s", spotifyBase, spotifyTrackID) - - apiBase := "https://api.song.link/v1-alpha.1/links?url=" - apiURL := fmt.Sprintf("%s%s", apiBase, 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", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") - fmt.Println("Getting Amazon URL...") - - resp, err := a.client.Do(req) + client := NewSongLinkClient() + urls, err := client.GetAllURLsFromSpotify(spotifyTrackID, "") if err != nil { return "", fmt.Errorf("failed to get Amazon URL: %w", err) } - defer resp.Body.Close() - if resp.StatusCode != 200 { - return "", fmt.Errorf("API returned status %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read response body: %w", err) - } - - if len(body) == 0 { - return "", fmt.Errorf("API returned empty response") - } - - var songLinkResp SongLinkResponse - if err := json.Unmarshal(body, &songLinkResp); err != nil { - - bodyStr := string(body) - if len(bodyStr) > 200 { - bodyStr = bodyStr[:200] + "..." - } - return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr) - } - - amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"] - if !ok || amazonLink.URL == "" { + amazonURL := normalizeAmazonMusicURL(urls.AmazonURL) + if amazonURL == "" { return "", fmt.Errorf("amazon Music link not found") } - - amazonURL := amazonLink.URL - - if strings.Contains(amazonURL, "trackAsin=") { - parts := strings.Split(amazonURL, "trackAsin=") - if len(parts) > 1 { - trackAsin := strings.Split(parts[1], "&")[0] - amazonURL = fmt.Sprintf("https://music.amazon.com/tracks/%s?musicTerritory=US", trackAsin) - } - } - fmt.Printf("Found Amazon URL: %s\n", amazonURL) return amazonURL, nil } @@ -111,7 +56,7 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL) } - apiURL := fmt.Sprintf("https://amzn.afkarxyz.fun/api/track/%s", asin) + apiURL := fmt.Sprintf("https://amzn.afkarxyz.qzz.io/api/track/%s", asin) req, err := http.NewRequest("GET", apiURL, nil) if err != nil { return "", err diff --git a/backend/analysis.go b/backend/analysis.go index a15ca78..daf0f0e 100644 --- a/backend/analysis.go +++ b/backend/analysis.go @@ -2,170 +2,26 @@ package backend import ( "fmt" - "math" "os" "os/exec" "strconv" "strings" "time" - - "github.com/go-flac/go-flac" - mewflac "github.com/mewkiz/flac" ) type AnalysisResult struct { - FilePath string `json:"file_path"` - FileSize int64 `json:"file_size"` - SampleRate uint32 `json:"sample_rate"` - Channels uint8 `json:"channels"` - BitsPerSample uint8 `json:"bits_per_sample"` - TotalSamples uint64 `json:"total_samples"` - Duration float64 `json:"duration"` - Bitrate int `json:"bit_rate"` - BitDepth string `json:"bit_depth"` - DynamicRange float64 `json:"dynamic_range"` - PeakAmplitude float64 `json:"peak_amplitude"` - RMSLevel float64 `json:"rms_level"` - Spectrum *SpectrumData `json:"spectrum,omitempty"` -} - -func AnalyzeTrack(filepath string) (*AnalysisResult, error) { - if !fileExists(filepath) { - return nil, fmt.Errorf("file does not exist: %s", filepath) - } - - fileInfo, err := os.Stat(filepath) - if err != nil { - return nil, fmt.Errorf("failed to get file info: %w", err) - } - - f, err := flac.ParseFile(filepath) - if err != nil { - return nil, fmt.Errorf("failed to parse FLAC file: %w", err) - } - - result := &AnalysisResult{ - FilePath: filepath, - FileSize: fileInfo.Size(), - } - - if len(f.Meta) > 0 { - streamInfo := f.Meta[0] - if streamInfo.Type == flac.StreamInfo { - - data := streamInfo.Data - if len(data) >= 18 { - - result.SampleRate = uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4 - - result.Channels = ((data[12] >> 1) & 0x07) + 1 - - result.BitsPerSample = ((data[12]&0x01)<<4 | data[13]>>4) + 1 - - result.TotalSamples = uint64(data[13]&0x0F)<<32 | - uint64(data[14])<<24 | - uint64(data[15])<<16 | - uint64(data[16])<<8 | - uint64(data[17]) - - if result.SampleRate > 0 { - result.Duration = float64(result.TotalSamples) / float64(result.SampleRate) - } - - } - } - } - - spectrum, err := AnalyzeSpectrum(filepath) - if err != nil { - - fmt.Printf("Warning: failed to analyze spectrum: %v\n", err) - } else { - result.Spectrum = spectrum - - calculateRealAudioMetrics(result, filepath) - } - - result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample) - - return result, nil -} - -func calculateRealAudioMetrics(result *AnalysisResult, filepath string) { - - samples, err := decodeFLACForMetrics(filepath) - if err != nil { - return - } - - var peak float64 - var sumSquares float64 - - for _, sample := range samples { - absVal := sample - if absVal < 0 { - absVal = -absVal - } - if absVal > peak { - peak = absVal - } - sumSquares += sample * sample - } - - peakDB := 20.0 * math.Log10(peak) - result.PeakAmplitude = peakDB - - rms := math.Sqrt(sumSquares / float64(len(samples))) - rmsDB := 20.0 * math.Log10(rms) - result.RMSLevel = rmsDB - - result.DynamicRange = peakDB - rmsDB -} - -func decodeFLACForMetrics(filepath string) ([]float64, error) { - stream, err := mewflac.ParseFile(filepath) - if err != nil { - return nil, err - } - defer stream.Close() - - maxSamples := 10000000 - samples := make([]float64, 0, maxSamples) - - for { - frame, err := stream.ParseNext() - if err != nil { - break - } - - var channelSamples []int32 - if len(frame.Subframes) > 0 { - channelSamples = frame.Subframes[0].Samples - } - - maxVal := float64(int64(1) << (stream.Info.BitsPerSample - 1)) - for _, sample := range channelSamples { - if len(samples) >= maxSamples { - return samples, nil - } - normalized := float64(sample) / maxVal - samples = append(samples, normalized) - } - - if len(samples) >= maxSamples { - break - } - } - - return samples, nil -} - -func GetFileSize(filepath string) (int64, error) { - info, err := os.Stat(filepath) - if err != nil { - return 0, err - } - return info.Size(), nil + FilePath string `json:"file_path"` + FileSize int64 `json:"file_size"` + SampleRate uint32 `json:"sample_rate"` + Channels uint8 `json:"channels"` + BitsPerSample uint8 `json:"bits_per_sample"` + TotalSamples uint64 `json:"total_samples"` + Duration float64 `json:"duration"` + Bitrate int `json:"bit_rate"` + BitDepth string `json:"bit_depth"` + DynamicRange float64 `json:"dynamic_range"` + PeakAmplitude float64 `json:"peak_amplitude"` + RMSLevel float64 `json:"rms_level"` } func GetTrackMetadata(filepath string) (*AnalysisResult, error) { @@ -194,20 +50,23 @@ func GetMetadataWithFFprobe(filePath string) (*AnalysisResult, error) { "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample,duration,bit_rate", - "-of", "default=noprint_wrappers=1:nokey=1", + "-of", "default=noprint_wrappers=0", filePath, } - cmd := exec.Command(ffprobePath, args...) setHideWindow(cmd) output, err := cmd.CombinedOutput() if err != nil { - return nil, fmt.Errorf("ffprobe failed: %w - %s", err, string(output)) + return nil, fmt.Errorf("ffprobe failed: %v - %s", err, string(output)) } - lines := strings.Split(strings.TrimSpace(string(output)), "\n") - if len(lines) < 4 { - return nil, fmt.Errorf("unexpected ffprobe output: %s", string(output)) + infoMap := make(map[string]string) + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if strings.Contains(line, "=") { + parts := strings.SplitN(line, "=", 2) + infoMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } } res := &AnalysisResult{ @@ -218,28 +77,6 @@ func GetMetadataWithFFprobe(filePath string) (*AnalysisResult, error) { res.FileSize = info.Size() } - infoMap := make(map[string]string) - - args = []string{ - "-v", "error", - "-select_streams", "a:0", - "-show_entries", "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample,duration,bit_rate", - "-of", "default=noprint_wrappers=0", - filePath, - } - cmd = exec.Command(ffprobePath, args...) - setHideWindow(cmd) - output, err = cmd.CombinedOutput() - if err == nil { - lines = strings.Split(string(output), "\n") - for _, line := range lines { - if strings.Contains(line, "=") { - parts := strings.SplitN(line, "=", 2) - infoMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) - } - } - } - if val, ok := infoMap["sample_rate"]; ok { s, _ := strconv.Atoi(val) res.SampleRate = uint32(s) diff --git a/backend/cover.go b/backend/cover.go index 843dae1..e40256a 100644 --- a/backend/cover.go +++ b/backend/cover.go @@ -1,7 +1,10 @@ package backend import ( + "bytes" "fmt" + "image" + "image/png" "io" "net/http" "os" @@ -9,6 +12,9 @@ import ( "regexp" "strings" "time" + + xdraw "golang.org/x/image/draw" + _ "image/jpeg" ) const ( @@ -170,6 +176,69 @@ func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQ return nil } +func (c *CoverClient) ApplyMacOSFLACFileIcon(filePath, coverURL string, iconSize int, embedMaxQualityCover bool) error { + if filePath == "" { + return fmt.Errorf("file path is required") + } + if coverURL == "" { + return fmt.Errorf("cover URL is required") + } + + tmpFile, err := os.CreateTemp("", "spotiflac-file-icon-*.jpg") + if err != nil { + return fmt.Errorf("failed to create temporary cover file: %w", err) + } + tmpPath := tmpFile.Name() + tmpFile.Close() + defer os.Remove(tmpPath) + + if err := c.DownloadCoverToPath(coverURL, tmpPath, embedMaxQualityCover); err != nil { + return err + } + + return SetMacOSFileIconFromImage(filePath, tmpPath, iconSize) +} + +func ResizeImageForIcon(sourcePath string, iconSize int) (string, error) { + if sourcePath == "" { + return "", fmt.Errorf("source image path is required") + } + if iconSize <= 0 { + iconSize = 256 + } + + in, err := os.Open(sourcePath) + if err != nil { + return "", fmt.Errorf("failed to open source image: %w", err) + } + defer in.Close() + + srcImage, _, err := image.Decode(in) + if err != nil { + return "", fmt.Errorf("failed to decode source image: %w", err) + } + + dst := image.NewRGBA(image.Rect(0, 0, iconSize, iconSize)) + xdraw.CatmullRom.Scale(dst, dst.Bounds(), srcImage, srcImage.Bounds(), xdraw.Over, nil) + + tmpFile, err := os.CreateTemp("", "spotiflac-resized-icon-*.png") + if err != nil { + return "", fmt.Errorf("failed to create resized icon temp file: %w", err) + } + tmpPath := tmpFile.Name() + defer tmpFile.Close() + + var encoded bytes.Buffer + if err := png.Encode(&encoded, dst); err != nil { + return "", fmt.Errorf("failed to encode resized icon image: %w", err) + } + if _, err := io.Copy(tmpFile, &encoded); err != nil { + return "", fmt.Errorf("failed to write resized icon image: %w", err) + } + + return tmpPath, nil +} + func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadResponse, error) { if req.CoverURL == "" { return &CoverDownloadResponse{ diff --git a/backend/download_validation.go b/backend/download_validation.go new file mode 100644 index 0000000..a465211 --- /dev/null +++ b/backend/download_validation.go @@ -0,0 +1,44 @@ +package backend + +import ( + "fmt" + "math" +) + +const ( + previewMaxSeconds = 35 + previewExpectedMinSeconds = 60 + largeMismatchMinExpected = 90 + minAllowedDurationDiff = 15 + durationDiffRatio = 0.25 +) + +func ValidateDownloadedTrackDuration(filePath string, expectedSeconds int) (bool, error) { + if filePath == "" || expectedSeconds <= 0 { + return false, nil + } + + actualDuration, err := GetAudioDuration(filePath) + if err != nil || actualDuration <= 0 { + return false, nil + } + + actualSeconds := int(math.Round(actualDuration)) + if actualSeconds <= 0 { + return false, nil + } + + if expectedSeconds >= previewExpectedMinSeconds && actualSeconds <= previewMaxSeconds { + return true, fmt.Errorf("detected preview/sample download: file is %ds, expected about %ds. file was removed", actualSeconds, expectedSeconds) + } + + if expectedSeconds >= largeMismatchMinExpected { + allowedDiff := int(math.Max(minAllowedDurationDiff, math.Round(float64(expectedSeconds)*durationDiffRatio))) + diff := int(math.Abs(float64(actualSeconds - expectedSeconds))) + if diff > allowedDiff { + return true, fmt.Errorf("downloaded file duration mismatch: file is %ds, expected about %ds. file was removed", actualSeconds, expectedSeconds) + } + } + + return true, nil +} diff --git a/backend/ffmpeg.go b/backend/ffmpeg.go index 7d824ed..5457c93 100644 --- a/backend/ffmpeg.go +++ b/backend/ffmpeg.go @@ -16,6 +16,7 @@ import ( "time" "github.com/ulikunitz/xz" + "golang.org/x/text/unicode/norm" ) func ValidateExecutable(path string) error { @@ -650,6 +651,7 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) { outputExt := "." + strings.ToLower(req.OutputFormat) outputFile := filepath.Join(outputDir, baseName+outputExt) + outputFile = norm.NFC.String(outputFile) if inputExt == outputExt { result.Error = "Input and output formats are the same" @@ -671,7 +673,11 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) { fmt.Printf("[FFmpeg] Warning: Failed to extract metadata from %s: %v\n", inputFile, err) } - coverArtPath, _ = ExtractCoverArt(inputFile) + inputFile = norm.NFC.String(inputFile) + coverArtPath, err = ExtractCoverArt(inputFile) + if err != nil { + fmt.Printf("[FFmpeg] Warning: Failed to extract cover art from %s: %v\n", inputFile, err) + } lyrics, err = ExtractLyrics(inputFile) if err != nil { fmt.Printf("[FFmpeg] Warning: Failed to extract lyrics from %s: %v\n", inputFile, err) diff --git a/backend/fileicon_darwin.go b/backend/fileicon_darwin.go new file mode 100644 index 0000000..dc62cbf --- /dev/null +++ b/backend/fileicon_darwin.go @@ -0,0 +1,45 @@ +//go:build darwin + +package backend + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +func SetMacOSFileIconFromImage(filePath, imagePath string, iconSize int) error { + if filePath == "" { + return fmt.Errorf("file path is required") + } + if imagePath == "" { + return fmt.Errorf("image path is required") + } + + resizedPath, err := ResizeImageForIcon(imagePath, iconSize) + if err != nil { + return err + } + defer os.Remove(resizedPath) + + script := ` +use framework "AppKit" +on run argv + set imagePath to item 1 of argv + set targetPath to item 2 of argv + set iconImage to current application's NSImage's alloc()'s initWithContentsOfFile:imagePath + if iconImage is missing value then error "Failed to load icon image" + set didSet to (current application's NSWorkspace's sharedWorkspace()'s setIcon:iconImage forFile:targetPath options:0) as boolean + if didSet is false then error "Failed to set custom file icon" +end run +` + + cmd := exec.Command("osascript", "-", resizedPath, filePath) + cmd.Stdin = strings.NewReader(script) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to apply macOS file icon: %v (%s)", err, strings.TrimSpace(string(output))) + } + return nil +} diff --git a/backend/fileicon_stub.go b/backend/fileicon_stub.go new file mode 100644 index 0000000..31be330 --- /dev/null +++ b/backend/fileicon_stub.go @@ -0,0 +1,7 @@ +//go:build !darwin + +package backend + +func SetMacOSFileIconFromImage(filePath, imagePath string, iconSize int) error { + return nil +} diff --git a/backend/folder.go b/backend/folder.go index f7934f2..db33d2a 100644 --- a/backend/folder.go +++ b/backend/folder.go @@ -50,12 +50,28 @@ func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error) func SelectFileDialog(ctx context.Context) (string, error) { options := wailsRuntime.OpenDialogOptions{ - Title: "Select FLAC File for Analysis", + Title: "Select Audio File for Analysis", Filters: []wailsRuntime.FileFilter{ + { + DisplayName: "Audio Files (*.flac;*.mp3;*.m4a;*.aac)", + Pattern: "*.flac;*.mp3;*.m4a;*.aac", + }, { DisplayName: "FLAC Audio Files (*.flac)", Pattern: "*.flac", }, + { + DisplayName: "MP3 Audio Files (*.mp3)", + Pattern: "*.mp3", + }, + { + DisplayName: "M4A Audio Files (*.m4a)", + Pattern: "*.m4a", + }, + { + DisplayName: "AAC Audio Files (*.aac)", + Pattern: "*.aac", + }, { DisplayName: "All Files (*.*)", Pattern: "*.*", diff --git a/backend/history.go b/backend/history.go index 11b6856..754db43 100644 --- a/backend/history.go +++ b/backend/history.go @@ -22,6 +22,7 @@ type HistoryItem struct { Quality string `json:"quality"` Format string `json:"format"` Path string `json:"path"` + Source string `json:"source"` Timestamp int64 `json:"timestamp"` } diff --git a/backend/metadata.go b/backend/metadata.go index 55b4c7e..d4a9e0a 100644 --- a/backend/metadata.go +++ b/backend/metadata.go @@ -13,6 +13,7 @@ import ( "github.com/go-flac/flacpicture" "github.com/go-flac/flacvorbis" "github.com/go-flac/go-flac" + "golang.org/x/text/unicode/norm" ) type Metadata struct { @@ -218,16 +219,68 @@ func EmbedLyricsOnly(filepath string, lyrics string) error { } func ExtractCoverArt(filePath string) (string, error) { + filePath = norm.NFC.String(filePath) ext := strings.ToLower(pathfilepath.Ext(filePath)) + var coverPath string + var err error + switch ext { case ".mp3": - return extractCoverFromMp3(filePath) + coverPath, err = extractCoverFromMp3(filePath) case ".m4a", ".flac": - return extractCoverFromM4AOrFlac(filePath) + coverPath, err = extractCoverFromM4AOrFlac(filePath) default: return "", fmt.Errorf("unsupported file format: %s", ext) } + + if err != nil || coverPath == "" { + fmt.Printf("[ExtractCoverArt] Library extraction failed for %s, trying FFmpeg fallback...\n", filePath) + ffmpegCover, ffmpegErr := extractCoverWithFFmpeg(filePath) + if ffmpegErr == nil { + return ffmpegCover, nil + } + return coverPath, err + } + + return coverPath, nil +} + +func extractCoverWithFFmpeg(filePath string) (string, error) { + ffmpegPath, err := GetFFmpegPath() + if err != nil { + return "", err + } + + tmpFile, err := os.CreateTemp("", "cover-*.jpg") + if err != nil { + return "", err + } + tmpPath := tmpFile.Name() + tmpFile.Close() + + cmd := exec.Command(ffmpegPath, + "-i", filePath, + "-an", + "-vframes", "1", + "-f", "image2", + "-update", "1", + "-y", + tmpPath, + ) + + setHideWindow(cmd) + if output, err := cmd.CombinedOutput(); err != nil { + os.Remove(tmpPath) + return "", fmt.Errorf("ffmpeg cover extraction failed: %v, output: %s", err, string(output)) + } + + if info, err := os.Stat(tmpPath); err != nil || info.Size() == 0 { + os.Remove(tmpPath) + return "", fmt.Errorf("ffmpeg produced empty cover file") + } + + return tmpPath, nil } func extractCoverFromMp3(filePath string) (string, error) { @@ -298,19 +351,71 @@ func extractCoverFromM4AOrFlac(filePath string) (string, error) { } func ExtractLyrics(filePath string) (string, error) { + filePath = norm.NFC.String(filePath) ext := strings.ToLower(pathfilepath.Ext(filePath)) + var lyrics string + var err error + switch ext { case ".mp3": - return extractLyricsFromMp3(filePath) + lyrics, err = extractLyricsFromMp3(filePath) case ".flac": - return extractLyricsFromFlac(filePath) + lyrics, err = extractLyricsFromFlac(filePath) case ".m4a": - return "", nil default: return "", fmt.Errorf("unsupported file format: %s", ext) } + + if (err != nil || lyrics == "") && ext != ".m4a" { + fmt.Printf("[ExtractLyrics] Library extraction failed for %s, trying ffprobe fallback...\n", filePath) + ffprobeLyrics, ffprobeErr := extractLyricsWithFFprobe(filePath) + if ffprobeErr == nil && ffprobeLyrics != "" { + return ffprobeLyrics, nil + } + } + + return lyrics, err +} + +func extractLyricsWithFFprobe(filePath string) (string, error) { + ffprobePath, err := GetFFprobePath() + if err != nil { + return "", err + } + + cmd := exec.Command(ffprobePath, + "-v", "quiet", + "-show_entries", "format_tags=lyrics:format_tags=unsyncedlyrics:format_tags=lyric", + "-of", "json", + filePath, + ) + + setHideWindow(cmd) + output, err := cmd.CombinedOutput() + if err != nil { + return "", err + } + + var result struct { + Format struct { + Tags map[string]string `json:"tags"` + } `json:"format"` + } + + if err := json.Unmarshal(output, &result); err != nil { + return "", err + } + + tags := result.Format.Tags + for _, key := range []string{"lyrics", "unsyncedlyrics", "lyric", "LYRICS", "UNSYNCEDLYRICS", "LYRIC"} { + if val, ok := tags[key]; ok && val != "" { + return val, nil + } + } + + return "", nil } func extractLyricsFromMp3(filePath string) (string, error) { @@ -688,6 +793,7 @@ func parseLRCTimestamp(timestamp string) int64 { } func ExtractFullMetadataFromFile(filePath string) (Metadata, error) { + filePath = norm.NFC.String(filePath) var metadata Metadata ffprobePath, err := GetFFprobePath() @@ -796,6 +902,7 @@ func ExtractFullMetadataFromFile(filePath string) (Metadata, error) { } func EmbedMetadataToConvertedFile(filePath string, metadata Metadata, coverPath string) error { + filePath = norm.NFC.String(filePath) ext := strings.ToLower(pathfilepath.Ext(filePath)) switch ext { diff --git a/backend/musicbrainz.go b/backend/musicbrainz.go index 84ab114..2cca4af 100644 --- a/backend/musicbrainz.go +++ b/backend/musicbrainz.go @@ -77,7 +77,7 @@ func FetchMusicBrainzMetadata(isrc, title, artist, album string, useSingleGenre return meta, err } - req.Header.Set("User-Agent", fmt.Sprintf("SpotiFLAC/%s ( support@exyezed.cc )", AppVersion)) + req.Header.Set("User-Agent", fmt.Sprintf("SpotiFLAC/%s ( hi@afkarxyz.qzz.io )", AppVersion)) var resp *http.Response var lastErr error diff --git a/backend/qobuz.go b/backend/qobuz.go index 7dffe75..3501060 100644 --- a/backend/qobuz.go +++ b/backend/qobuz.go @@ -119,7 +119,7 @@ func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) { } func buildQobuzAPIURL(apiBase string, trackID int64, quality string) string { - if strings.Contains(apiBase, "qbz.afkarxyz.fun") { + if strings.Contains(apiBase, "qbz.afkarxyz.qzz.io") { return fmt.Sprintf("%s%d?quality=%s", apiBase, trackID, quality) } return fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality) @@ -174,7 +174,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal standardAPIs := []string{ "https://dab.yeet.su/api/stream?trackId=", "https://dabmusic.xyz/api/stream?trackId=", - "https://qbz.afkarxyz.fun/api/track/", + "https://qbz.afkarxyz.qzz.io/api/track/", } downloadFunc := func(qual string) (string, error) { @@ -365,7 +365,7 @@ func (q *QobuzDownloader) DownloadTrack(spotifyID, outputDir, quality, filenameF var deezerISRC string if spotifyID != "" { songlinkClient := NewSongLinkClient() - isrc, err := songlinkClient.GetISRC(spotifyID) + isrc, err := songlinkClient.GetISRCDirect(spotifyID) if err != nil { return "", fmt.Errorf("failed to get ISRC: %v", err) } diff --git a/backend/resample.go b/backend/resample.go new file mode 100644 index 0000000..9d53ec1 --- /dev/null +++ b/backend/resample.go @@ -0,0 +1,223 @@ +package backend + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" +) + +type FlacInfo struct { + Path string `json:"path"` + SampleRate uint32 `json:"sample_rate"` + BitsPerSample uint8 `json:"bits_per_sample"` +} + +func GetFlacInfoBatch(paths []string) []FlacInfo { + results := make([]FlacInfo, len(paths)) + var wg sync.WaitGroup + + for i, path := range paths { + wg.Add(1) + go func(idx int, p string) { + defer wg.Done() + info := FlacInfo{Path: p} + + ffprobePath, err := GetFFprobePath() + if err != nil { + results[idx] = info + return + } + + args := []string{ + "-v", "error", + "-select_streams", "a:0", + "-show_entries", "stream=sample_rate,bits_per_raw_sample,bits_per_sample", + "-of", "default=noprint_wrappers=0", + p, + } + cmd := exec.Command(ffprobePath, args...) + setHideWindow(cmd) + out, err := cmd.CombinedOutput() + if err != nil { + results[idx] = info + return + } + + kvMap := make(map[string]string) + for _, line := range strings.Split(string(out), "\n") { + if parts := strings.SplitN(line, "=", 2); len(parts) == 2 { + kvMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + } + + if v, ok := kvMap["sample_rate"]; ok { + if s, err := strconv.Atoi(v); err == nil { + info.SampleRate = uint32(s) + } + } + + bits := 0 + if v, ok := kvMap["bits_per_raw_sample"]; ok && v != "N/A" && v != "" { + bits, _ = strconv.Atoi(v) + } + if bits == 0 { + if v, ok := kvMap["bits_per_sample"]; ok && v != "N/A" && v != "" { + bits, _ = strconv.Atoi(v) + } + } + info.BitsPerSample = uint8(bits) + + results[idx] = info + }(i, path) + } + + wg.Wait() + return results +} + +type ResampleRequest struct { + InputFiles []string `json:"input_files"` + SampleRate string `json:"sample_rate"` + BitDepth string `json:"bit_depth"` +} + +type ResampleResult struct { + InputFile string `json:"input_file"` + OutputFile string `json:"output_file"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +func buildFolderLabel(sampleRate, bitDepth string) string { + var parts []string + + if bitDepth != "" { + parts = append(parts, bitDepth+"bit") + } + + switch sampleRate { + case "44100": + parts = append(parts, "44.1kHz") + case "48000": + parts = append(parts, "48kHz") + case "96000": + parts = append(parts, "96kHz") + case "192000": + parts = append(parts, "192kHz") + default: + if sampleRate != "" { + parts = append(parts, sampleRate+"Hz") + } + } + + if len(parts) == 0 { + return "Resampled" + } + return strings.Join(parts, " ") +} + +func ResampleAudio(req ResampleRequest) ([]ResampleResult, error) { + ffmpegPath, err := GetFFmpegPath() + if err != nil { + return nil, fmt.Errorf("failed to get ffmpeg path: %w", err) + } + + if err := ValidateExecutable(ffmpegPath); err != nil { + return nil, fmt.Errorf("invalid ffmpeg executable: %w", err) + } + + installed, err := IsFFmpegInstalled() + if err != nil || !installed { + return nil, fmt.Errorf("ffmpeg is not installed") + } + + if req.SampleRate == "" && req.BitDepth == "" { + return nil, fmt.Errorf("at least one of sample rate or bit depth must be specified") + } + + results := make([]ResampleResult, len(req.InputFiles)) + var wg sync.WaitGroup + var mu sync.Mutex + + folderLabel := buildFolderLabel(req.SampleRate, req.BitDepth) + + for i, inputFile := range req.InputFiles { + wg.Add(1) + go func(idx int, inputFile string) { + defer wg.Done() + + result := ResampleResult{ + InputFile: inputFile, + } + + inputExt := strings.ToLower(filepath.Ext(inputFile)) + baseName := strings.TrimSuffix(filepath.Base(inputFile), inputExt) + inputDir := filepath.Dir(inputFile) + + outputDir := filepath.Join(inputDir, folderLabel) + if err := os.MkdirAll(outputDir, 0755); err != nil { + result.Error = fmt.Sprintf("failed to create output directory: %v", err) + result.Success = false + mu.Lock() + results[idx] = result + mu.Unlock() + return + } + + outputFile := filepath.Join(outputDir, baseName+".flac") + result.OutputFile = outputFile + + args := []string{ + "-i", inputFile, + "-y", + } + + if req.BitDepth != "" { + switch req.BitDepth { + case "16": + args = append(args, "-c:a", "flac", "-sample_fmt", "s16") + case "24": + args = append(args, "-c:a", "flac", "-sample_fmt", "s32", "-bits_per_raw_sample", "24") + default: + args = append(args, "-c:a", "flac") + } + } else { + args = append(args, "-c:a", "flac") + } + + if req.SampleRate != "" { + args = append(args, "-ar", req.SampleRate) + } + + args = append(args, "-map_metadata", "0") + args = append(args, outputFile) + + fmt.Printf("[Resample] %s -> %s\n", inputFile, outputFile) + + cmd := exec.Command(ffmpegPath, args...) + setHideWindow(cmd) + output, err := cmd.CombinedOutput() + if err != nil { + result.Error = fmt.Sprintf("resampling failed: %s - %s", err.Error(), string(output)) + result.Success = false + mu.Lock() + results[idx] = result + mu.Unlock() + return + } + + result.Success = true + fmt.Printf("[Resample] Done: %s\n", outputFile) + mu.Lock() + results[idx] = result + mu.Unlock() + }(i, inputFile) + } + + wg.Wait() + return results, nil +} diff --git a/backend/songlink.go b/backend/songlink.go index f8eef51..ae869b7 100644 --- a/backend/songlink.go +++ b/backend/songlink.go @@ -2,19 +2,31 @@ package backend import ( "encoding/json" + "errors" "fmt" + "html" "io" "net/http" + "net/http/cookiejar" "net/url" + "regexp" "strings" "time" ) +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})`) +) + type SongLinkClient struct { - client *http.Client - lastAPICallTime time.Time - apiCallCount int - apiCallResetTime time.Time + client *http.Client } type SongLinkURLs struct { @@ -35,136 +47,44 @@ type TrackAvailability struct { DeezerURL string `json:"deezer_url,omitempty"` } +type songLinkAPIResponse struct { + LinksByPlatform map[string]struct { + URL string `json:"url"` + } `json:"linksByPlatform"` +} + +type resolvedTrackLinks struct { + TidalURL string + AmazonURL string + DeezerURL string + ISRC string +} + func NewSongLinkClient() *SongLinkClient { return &SongLinkClient{ client: &http.Client{ Timeout: 30 * time.Second, }, - apiCallResetTime: time.Now(), } } func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string, region string) (*SongLinkURLs, error) { - - now := time.Now() - if now.Sub(s.apiCallResetTime) >= time.Minute { - s.apiCallCount = 0 - s.apiCallResetTime = now - } - - if s.apiCallCount >= 9 { - waitTime := time.Minute - now.Sub(s.apiCallResetTime) - if waitTime > 0 { - fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second)) - time.Sleep(waitTime) - s.apiCallCount = 0 - s.apiCallResetTime = time.Now() - } - } - - if !s.lastAPICallTime.IsZero() { - timeSinceLastCall := now.Sub(s.lastAPICallTime) - minDelay := 7 * time.Second - if timeSinceLastCall < minDelay { - waitTime := minDelay - timeSinceLastCall - fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second)) - time.Sleep(waitTime) - } - } - - spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID) - - apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(spotifyURL)) - - if region != "" { - apiURL += fmt.Sprintf("&userCountry=%s", region) - } - - req, err := http.NewRequest("GET", apiURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - fmt.Println("Getting streaming URLs from song.link...") - - maxRetries := 3 - var resp *http.Response - for i := 0; i < maxRetries; i++ { - resp, err = s.client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get URLs: %w", err) - } - - s.lastAPICallTime = time.Now() - s.apiCallCount++ - - if resp.StatusCode == 429 { - resp.Body.Close() - if i < maxRetries-1 { - waitTime := 15 * time.Second - fmt.Printf("Rate limited by API, waiting %v before retry...\n", waitTime) - time.Sleep(waitTime) - continue - } - return nil, fmt.Errorf("API rate limit exceeded after %d retries", maxRetries) - } - - if resp.StatusCode != 200 { - resp.Body.Close() - return nil, fmt.Errorf("API returned status %d", resp.StatusCode) - } - - break - } - defer resp.Body.Close() - - var songLinkResp struct { - LinksByPlatform map[string]struct { - URL string `json:"url"` - } `json:"linksByPlatform"` - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - if len(body) == 0 { - return nil, fmt.Errorf("API returned empty response") - } - - if err := json.Unmarshal(body, &songLinkResp); err != nil { - - bodyStr := string(body) - if len(bodyStr) > 200 { - bodyStr = bodyStr[:200] + "..." - } - return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr) + links, err := s.resolveSpotifyTrackLinks(spotifyTrackID, region) + if err != nil && (links == nil || (links.TidalURL == "" && links.AmazonURL == "")) { + return nil, err } urls := &SongLinkURLs{} - - if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" { - urls.TidalURL = tidalLink.URL - fmt.Printf("✓ Tidal URL found\n") - } - - if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" { - amazonURL := amazonLink.URL - - if len(amazonURL) > 0 { - urls.AmazonURL = amazonURL - fmt.Printf("✓ Amazon URL found\n") - } - } - - if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { - if isrc, err := getDeezerISRC(deezerLink.URL); err == nil && isrc != "" { - urls.ISRC = isrc - } + if links != nil { + urls.TidalURL = links.TidalURL + urls.AmazonURL = normalizeAmazonMusicURL(links.AmazonURL) + urls.ISRC = links.ISRC } if urls.TidalURL == "" && urls.AmazonURL == "" { + if err != nil { + return nil, err + } return nil, fmt.Errorf("no streaming URLs found") } @@ -172,126 +92,53 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string, region str } func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string) (*TrackAvailability, error) { - - now := time.Now() - if now.Sub(s.apiCallResetTime) >= time.Minute { - s.apiCallCount = 0 - s.apiCallResetTime = now - } - - if s.apiCallCount >= 9 { - waitTime := time.Minute - now.Sub(s.apiCallResetTime) - if waitTime > 0 { - fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second)) - time.Sleep(waitTime) - s.apiCallCount = 0 - s.apiCallResetTime = time.Now() - } - } - - if !s.lastAPICallTime.IsZero() { - timeSinceLastCall := now.Sub(s.lastAPICallTime) - minDelay := 7 * time.Second - if timeSinceLastCall < minDelay { - waitTime := minDelay - timeSinceLastCall - fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second)) - time.Sleep(waitTime) - } - } - - spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID) - - apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(spotifyURL)) - - req, err := http.NewRequest("GET", apiURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - fmt.Printf("Checking availability for track: %s\n", spotifyTrackID) - - maxRetries := 3 - var resp *http.Response - for i := 0; i < maxRetries; i++ { - resp, err = s.client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to check availability: %w", err) - } - - s.lastAPICallTime = time.Now() - s.apiCallCount++ - - if resp.StatusCode == 429 { - resp.Body.Close() - if i < maxRetries-1 { - waitTime := 15 * time.Second - fmt.Printf("Rate limited by API, waiting %v before retry...\n", waitTime) - time.Sleep(waitTime) - continue - } - return nil, fmt.Errorf("API rate limit exceeded after %d retries", maxRetries) - } - - if resp.StatusCode != 200 { - resp.Body.Close() - return nil, fmt.Errorf("API returned status %d", resp.StatusCode) - } - - break - } - defer resp.Body.Close() - - var songLinkResp struct { - LinksByPlatform map[string]struct { - URL string `json:"url"` - } `json:"linksByPlatform"` - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - if len(body) == 0 { - return nil, fmt.Errorf("API returned empty response") - } - - if err := json.Unmarshal(body, &songLinkResp); err != nil { - - bodyStr := string(body) - if len(bodyStr) > 200 { - bodyStr = bodyStr[:200] + "..." - } - return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr) - } + links, err := s.resolveSpotifyTrackLinks(spotifyTrackID, "") availability := &TrackAvailability{ SpotifyID: spotifyTrackID, } - if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" { - availability.Tidal = true - availability.TidalURL = tidalLink.URL + if links != nil { + availability.TidalURL = links.TidalURL + availability.AmazonURL = normalizeAmazonMusicURL(links.AmazonURL) + availability.DeezerURL = normalizeDeezerTrackURL(links.DeezerURL) + availability.Tidal = availability.TidalURL != "" + availability.Amazon = availability.AmazonURL != "" + availability.Deezer = availability.DeezerURL != "" } - if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" { - availability.Amazon = true - availability.AmazonURL = amazonLink.URL + isrc := "" + if links != nil { + isrc = strings.TrimSpace(links.ISRC) } - if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { - deezerURL := deezerLink.URL - availability.Deezer = true - availability.DeezerURL = deezerURL - - deezerISRC, err := getDeezerISRC(deezerURL) - if err == nil && deezerISRC != "" { - qobuzAvailable := checkQobuzAvailability(deezerISRC) - availability.Qobuz = qobuzAvailable + if isrc == "" && availability.DeezerURL != "" { + if deezerISRC, deezerErr := getDeezerISRC(availability.DeezerURL); deezerErr == nil { + isrc = deezerISRC } } - return availability, nil + if isrc == "" { + if fallbackISRC, fallbackErr := s.lookupSpotifyISRC(spotifyTrackID); fallbackErr == nil { + isrc = fallbackISRC + } else if err == nil { + err = fallbackErr + } + } + + if isrc != "" { + availability.Qobuz = checkQobuzAvailability(isrc) + } + + if availability.Tidal || availability.Amazon || availability.Deezer || availability.Qobuz { + return availability, nil + } + + if err != nil { + return availability, err + } + + return availability, fmt.Errorf("no platforms found") } func checkQobuzAvailability(isrc string) bool { @@ -323,107 +170,47 @@ func checkQobuzAvailability(isrc string) bool { } func (s *SongLinkClient) GetDeezerURLFromSpotify(spotifyTrackID string) (string, error) { - - now := time.Now() - if now.Sub(s.apiCallResetTime) >= time.Minute { - s.apiCallCount = 0 - s.apiCallResetTime = now + links, err := s.resolveSpotifyTrackLinks(spotifyTrackID, "") + if links != nil && links.DeezerURL != "" { + deezerURL := normalizeDeezerTrackURL(links.DeezerURL) + fmt.Printf("Found Deezer URL: %s\n", deezerURL) + return deezerURL, nil } - if s.apiCallCount >= 9 { - waitTime := time.Minute - now.Sub(s.apiCallResetTime) - if waitTime > 0 { - fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second)) - time.Sleep(waitTime) - s.apiCallCount = 0 - s.apiCallResetTime = time.Now() + isrc := "" + if links != nil { + isrc = strings.TrimSpace(links.ISRC) + } + if isrc == "" { + fallbackISRC, lookupErr := s.lookupSpotifyISRC(spotifyTrackID) + if lookupErr == nil { + isrc = fallbackISRC + } else if err == nil { + err = lookupErr } } - if !s.lastAPICallTime.IsZero() { - timeSinceLastCall := now.Sub(s.lastAPICallTime) - minDelay := 7 * time.Second - if timeSinceLastCall < minDelay { - waitTime := minDelay - timeSinceLastCall - fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second)) - time.Sleep(waitTime) + if isrc != "" { + deezerURL, deezerErr := s.lookupDeezerTrackURLByISRC(isrc) + if deezerErr == nil { + fmt.Printf("Found Deezer URL: %s\n", deezerURL) + return deezerURL, nil + } + if err == nil { + err = deezerErr } } - spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID) - - apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(spotifyURL)) - - req, err := http.NewRequest("GET", apiURL, nil) if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) + return "", err } - - fmt.Println("Getting Deezer URL from song.link...") - - maxRetries := 3 - var resp *http.Response - for i := 0; i < maxRetries; i++ { - resp, err = s.client.Do(req) - if err != nil { - return "", fmt.Errorf("failed to get Deezer URL: %w", err) - } - - s.lastAPICallTime = time.Now() - s.apiCallCount++ - - if resp.StatusCode == 429 { - resp.Body.Close() - if i < maxRetries-1 { - waitTime := 15 * time.Second - fmt.Printf("Rate limited by API, waiting %v before retry...\n", waitTime) - time.Sleep(waitTime) - continue - } - return "", fmt.Errorf("API rate limit exceeded after %d retries", maxRetries) - } - - if resp.StatusCode != 200 { - resp.Body.Close() - return "", fmt.Errorf("API returned status %d", resp.StatusCode) - } - - break - } - defer resp.Body.Close() - - var songLinkResp struct { - LinksByPlatform map[string]struct { - URL string `json:"url"` - } `json:"linksByPlatform"` - } - if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil { - return "", fmt.Errorf("failed to decode response: %w", err) - } - - deezerLink, ok := songLinkResp.LinksByPlatform["deezer"] - if !ok || deezerLink.URL == "" { - return "", fmt.Errorf("deezer link not found") - } - - deezerURL := deezerLink.URL - fmt.Printf("Found Deezer URL: %s\n", deezerURL) - return deezerURL, nil + return "", fmt.Errorf("deezer link not found") } func getDeezerISRC(deezerURL string) (string, error) { - - var trackID string - if strings.Contains(deezerURL, "/track/") { - parts := strings.Split(deezerURL, "/track/") - if len(parts) > 1 { - trackID = strings.Split(parts[1], "?")[0] - trackID = strings.TrimSpace(trackID) - } - } - - if trackID == "" { - return "", fmt.Errorf("could not extract track ID from Deezer URL: %s", deezerURL) + trackID, err := extractDeezerTrackID(deezerURL) + if err != nil { + return "", err } apiURL := fmt.Sprintf("https://api.deezer.com/track/%s", trackID) @@ -453,13 +240,686 @@ func getDeezerISRC(deezerURL string) (string, error) { } fmt.Printf("Found ISRC from Deezer: %s (track: %s)\n", deezerTrack.ISRC, deezerTrack.Title) - return deezerTrack.ISRC, nil + return strings.ToUpper(strings.TrimSpace(deezerTrack.ISRC)), nil } func (s *SongLinkClient) GetISRC(spotifyID string) (string, error) { - deezerURL, err := s.GetDeezerURLFromSpotify(spotifyID) + links, err := s.resolveSpotifyTrackLinks(spotifyID, "") + if links != nil && links.ISRC != "" { + return links.ISRC, nil + } + + if links != nil && links.DeezerURL != "" { + if isrc, deezerErr := getDeezerISRC(links.DeezerURL); deezerErr == nil { + return isrc, nil + } + } + + isrc, lookupErr := s.lookupSpotifyISRC(spotifyID) + if lookupErr == nil && isrc != "" { + return isrc, nil + } + + if err != nil && lookupErr != nil { + return "", fmt.Errorf("%v | %v", err, lookupErr) + } if err != nil { return "", err } - return getDeezerISRC(deezerURL) + if lookupErr != nil { + return "", lookupErr + } + + return "", fmt.Errorf("ISRC not found") +} + +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) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("User-Agent", songLinkUserAgent) + + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to call song.link: %w", err) + } + 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))) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read song.link response: %w", err) + } + if len(body) == 0 { + return nil, fmt.Errorf("song.link returned empty response") + } + + var parsed songLinkAPIResponse + if err := json.Unmarshal(body, &parsed); err != nil { + bodyStr := string(body) + if len(bodyStr) > 200 { + bodyStr = bodyStr[:200] + "..." + } + return nil, fmt.Errorf("failed to decode song.link response: %w (response: %s)", err, bodyStr) + } + + 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) + 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 call Deezer ISRC API: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("Deezer ISRC API returned status %d", resp.StatusCode) + } + + var payload struct { + ID int64 `json:"id"` + ISRC string `json:"isrc"` + Link string `json:"link"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", fmt.Errorf("failed to decode Deezer ISRC response: %w", err) + } + + if payload.Link != "" { + return normalizeDeezerTrackURL(payload.Link), nil + } + if payload.ID > 0 { + return normalizeDeezerTrackURL(fmt.Sprintf("https://www.deezer.com/track/%d", payload.ID)), nil + } + + return "", fmt.Errorf("deezer track link not found for ISRC %s", isrc) +} + +func mergeSongLinkResponse(links *resolvedTrackLinks, resp *songLinkAPIResponse) { + if resp == nil { + return + } + + if link, ok := resp.LinksByPlatform["tidal"]; ok && link.URL != "" && links.TidalURL == "" { + links.TidalURL = strings.TrimSpace(link.URL) + fmt.Println("✓ Tidal URL found") + } + + if link, ok := resp.LinksByPlatform["amazonMusic"]; ok && link.URL != "" && links.AmazonURL == "" { + links.AmazonURL = normalizeAmazonMusicURL(link.URL) + fmt.Println("✓ Amazon URL found") + } + + if link, ok := resp.LinksByPlatform["deezer"]; ok && link.URL != "" && links.DeezerURL == "" { + links.DeezerURL = normalizeDeezerTrackURL(link.URL) + fmt.Println("✓ Deezer URL found") + } +} + +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 == "" { + return "" + } + + if strings.Contains(amazonURL, "trackAsin=") { + parts := strings.Split(amazonURL, "trackAsin=") + if len(parts) > 1 { + trackAsin := strings.Split(parts[1], "&")[0] + if trackAsin != "" { + return fmt.Sprintf("https://music.amazon.com/tracks/%s?musicTerritory=US", trackAsin) + } + } + } + + if match := amazonAlbumTrackPath.FindStringSubmatch(amazonURL); len(match) > 1 { + return fmt.Sprintf("https://music.amazon.com/tracks/%s?musicTerritory=US", match[1]) + } + + if match := amazonTrackPath.FindStringSubmatch(amazonURL); len(match) > 1 { + return fmt.Sprintf("https://music.amazon.com/tracks/%s?musicTerritory=US", match[1]) + } + + return "" +} + +func normalizeDeezerTrackURL(rawURL string) string { + trackID, err := extractDeezerTrackID(rawURL) + if err != nil { + return strings.TrimSpace(rawURL) + } + return fmt.Sprintf("https://www.deezer.com/track/%s", trackID) +} + +func extractDeezerTrackID(rawURL string) (string, error) { + cleanURL := strings.TrimSpace(rawURL) + if cleanURL == "" { + return "", fmt.Errorf("empty Deezer URL") + } + + parts := strings.Split(cleanURL, "/track/") + if len(parts) < 2 { + return "", fmt.Errorf("could not extract track ID from Deezer URL: %s", rawURL) + } + + trackID := strings.Split(parts[1], "?")[0] + trackID = strings.Trim(trackID, "/ ") + if trackID == "" { + return "", fmt.Errorf("could not extract track ID from Deezer URL: %s", rawURL) + } + + return trackID, nil +} + +func hasAnySongLinkData(links *resolvedTrackLinks) bool { + if links == nil { + return false + } + 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 { + return "" + } + 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/spectrum.go b/backend/spectrum.go deleted file mode 100644 index fe7b053..0000000 --- a/backend/spectrum.go +++ /dev/null @@ -1,181 +0,0 @@ -package backend - -import ( - "fmt" - "math" - "math/cmplx" - - "github.com/mewkiz/flac" -) - -type SpectrumData struct { - TimeSlices []TimeSlice `json:"time_slices"` - SampleRate int `json:"sample_rate"` - FreqBins int `json:"freq_bins"` - Duration float64 `json:"duration"` - MaxFreq float64 `json:"max_freq"` -} - -type TimeSlice struct { - Time float64 `json:"time"` - Magnitudes []float64 `json:"magnitudes"` -} - -func AnalyzeSpectrum(filepath string) (*SpectrumData, error) { - - stream, err := flac.ParseFile(filepath) - if err != nil { - return nil, fmt.Errorf("failed to parse FLAC: %w", err) - } - defer stream.Close() - - info := stream.Info - sampleRate := int(info.SampleRate) - channels := int(info.NChannels) - - samples, err := readSamples(stream, channels) - if err != nil { - return nil, fmt.Errorf("failed to read samples: %w", err) - } - - if len(samples) == 0 { - return nil, fmt.Errorf("no audio samples found") - } - - return calculateSpectrum(samples, sampleRate), nil -} - -func readSamples(stream *flac.Stream, channels int) ([]float64, error) { - var allSamples []float64 - maxSamples := 10 * 1024 * 1024 - - for { - frame, err := stream.ParseNext() - if err != nil { - - break - } - - for i := 0; i < frame.Subframes[0].NSamples; i++ { - var sample float64 - - for ch := 0; ch < channels; ch++ { - sample += float64(frame.Subframes[ch].Samples[i]) - } - sample /= float64(channels) - - allSamples = append(allSamples, sample) - - if len(allSamples) >= maxSamples { - return allSamples, nil - } - } - } - - return allSamples, nil -} - -func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData { - fftSize := 8192 - numTimeSlices := 300 - - duration := float64(len(samples)) / float64(sampleRate) - - samplesPerSlice := len(samples) / numTimeSlices - if samplesPerSlice < fftSize { - samplesPerSlice = fftSize - numTimeSlices = len(samples) / fftSize - } - - timeSlices := make([]TimeSlice, 0, numTimeSlices) - freqBins := fftSize / 2 - maxFreq := float64(sampleRate) / 2.0 - - for i := 0; i < numTimeSlices; i++ { - startIdx := i * samplesPerSlice - if startIdx+fftSize > len(samples) { - break - } - - window := samples[startIdx : startIdx+fftSize] - - windowedSamples := applyHannWindow(window) - - spectrum := fft(windowedSamples) - - magnitudes := make([]float64, freqBins) - for j := 0; j < freqBins; j++ { - magnitude := cmplx.Abs(spectrum[j]) - - if magnitude < 1e-10 { - magnitude = 1e-10 - } - magnitudes[j] = 20 * math.Log10(magnitude) - } - - timeSlice := TimeSlice{ - Time: float64(startIdx) / float64(sampleRate), - Magnitudes: magnitudes, - } - timeSlices = append(timeSlices, timeSlice) - } - - return &SpectrumData{ - TimeSlices: timeSlices, - SampleRate: sampleRate, - FreqBins: freqBins, - Duration: duration, - MaxFreq: maxFreq, - } -} - -func applyHannWindow(samples []float64) []float64 { - n := len(samples) - windowed := make([]float64, n) - - for i := 0; i < n; i++ { - window := 0.5 * (1.0 - math.Cos(2.0*math.Pi*float64(i)/float64(n-1))) - windowed[i] = samples[i] * window - } - - return windowed -} - -func fft(samples []float64) []complex128 { - n := len(samples) - - x := make([]complex128, n) - for i := 0; i < n; i++ { - x[i] = complex(samples[i], 0) - } - - return fftRecursive(x) -} - -func fftRecursive(x []complex128) []complex128 { - n := len(x) - - if n <= 1 { - return x - } - - even := make([]complex128, n/2) - odd := make([]complex128, n/2) - - for i := 0; i < n/2; i++ { - even[i] = x[2*i] - odd[i] = x[2*i+1] - } - - evenFFT := fftRecursive(even) - oddFFT := fftRecursive(odd) - - result := make([]complex128, n) - for k := 0; k < n/2; k++ { - t := cmplx.Exp(complex(0, -2*math.Pi*float64(k)/float64(n))) * oddFFT[k] - result[k] = evenFFT[k] + t - result[k+n/2] = evenFFT[k] - t - } - - return result -} diff --git a/backend/spotfetch.go b/backend/spotfetch.go index 440154d..4ca171c 100644 --- a/backend/spotfetch.go +++ b/backend/spotfetch.go @@ -485,7 +485,7 @@ func extractDuration(ms float64) map[string]interface{} { } } -func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]interface{}) map[string]interface{} { +func FilterTrack(data map[string]interface{}, separator string, albumFetchData ...map[string]interface{}) map[string]interface{} { dataMap := getMap(data, "data") trackData := getMap(dataMap, "trackUnion") if len(trackData) == 0 { @@ -555,7 +555,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter copyrightData := getMap(albumData, "copyright") if len(copyrightData) > 0 { copyrightItems := getSlice(copyrightData, "items") - if copyrightItems != nil { + if len(copyrightItems) > 0 { for _, item := range copyrightItems { itemMap, ok := item.(map[string]interface{}) if !ok { @@ -574,7 +574,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter if len(tracksData) > 0 { discNumbers := make(map[int]bool) trackItems := getSlice(tracksData, "items") - if trackItems != nil { + if len(trackItems) > 0 { for _, item := range trackItems { itemMap, ok := item.(map[string]interface{}) if !ok { @@ -656,7 +656,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter albumArtistsString := "" albumLabel := "" - if albumFetchDataMap != nil && len(albumFetchDataMap) > 0 { + if len(albumFetchDataMap) > 0 { albumUnionData := getMap(getMap(albumFetchDataMap, "data"), "albumUnion") if len(albumUnionData) > 0 { albumArtists := extractArtists(getMap(albumUnionData, "artists")) @@ -665,7 +665,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter for _, artist := range albumArtists { albumArtistNames = append(albumArtistNames, getString(artist, "name")) } - albumArtistsString = strings.Join(albumArtistNames, GetSeparator()) + albumArtistsString = strings.Join(albumArtistNames, separator) } if albumArtistsString == "" { albumArtistsString = getString(albumUnionData, "artists") @@ -681,7 +681,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter for _, artist := range albumArtists { albumArtistNames = append(albumArtistNames, getString(artist, "name")) } - albumArtistsString = strings.Join(albumArtistNames, GetSeparator()) + albumArtistsString = strings.Join(albumArtistNames, separator) } } @@ -715,7 +715,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter for _, artist := range artists { artistNames = append(artistNames, getString(artist, "name")) } - artistsString := strings.Join(artistNames, GetSeparator()) + artistsString := strings.Join(artistNames, separator) copyrightTexts := []string{} for _, item := range copyrightInfo { @@ -802,7 +802,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter return filtered } -func FilterAlbum(data map[string]interface{}) map[string]interface{} { +func FilterAlbum(data map[string]interface{}, separator string) map[string]interface{} { dataMap := getMap(data, "data") albumData := getMap(dataMap, "albumUnion") if len(albumData) == 0 { @@ -814,7 +814,7 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} { for _, artist := range artists { artistNames = append(artistNames, getString(artist, "name")) } - albumArtistsString := strings.Join(artistNames, GetSeparator()) + albumArtistsString := strings.Join(artistNames, separator) coverObj := extractCoverImage(getMap(albumData, "coverArt")) var cover interface{} @@ -875,7 +875,7 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} { for _, artist := range trackArtists { trackArtistNames = append(trackArtistNames, getString(artist, "name")) } - trackArtistsString := strings.Join(trackArtistNames, GetSeparator()) + trackArtistsString := strings.Join(trackArtistNames, separator) trackURI := getString(track, "uri") trackID := "" @@ -943,7 +943,7 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} { return filtered } -func FilterPlaylist(data map[string]interface{}) map[string]interface{} { +func FilterPlaylist(data map[string]interface{}, separator string) map[string]interface{} { dataMap := getMap(data, "data") playlistData := getMap(dataMap, "playlistV2") if len(playlistData) == 0 { @@ -957,21 +957,9 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} { avatarData := getMap(ownerData, "avatar") if len(avatarData) > 0 { sources := getSlice(avatarData, "sources") - if sources != nil { - for _, source := range sources { - sourceMap, ok := source.(map[string]interface{}) - if !ok { - continue - } - if getFloat64(sourceMap, "width") == 300 { - avatarURL = getString(sourceMap, "url") - break - } - } - if avatarURL == nil && len(sources) > 0 { - if firstSource, ok := sources[0].(map[string]interface{}); ok { - avatarURL = getString(firstSource, "url") - } + if len(sources) > 0 { + if firstSource, ok := sources[0].(map[string]interface{}); ok { + avatarURL = getString(firstSource, "url") } } } @@ -1075,7 +1063,7 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} { for _, artist := range trackArtists { trackArtistNames = append(trackArtistNames, getString(artist, "name")) } - artistsString := strings.Join(trackArtistNames, GetSeparator()) + artistsString := strings.Join(trackArtistNames, separator) trackDurationMs := getFloat64(getMap(trackData, "trackDuration"), "totalMilliseconds") durationObj := extractDuration(trackDurationMs) @@ -1121,7 +1109,7 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} { for _, artist := range albumArtists { albumArtistNames = append(albumArtistNames, getString(artist, "name")) } - albumArtistsString = strings.Join(albumArtistNames, GetSeparator()) + albumArtistsString = strings.Join(albumArtistNames, separator) } } @@ -1291,11 +1279,11 @@ func extractDiscographyItems(itemsData map[string]interface{}) []map[string]inte } func stripHTMLTags(s string) string { - re := regexp.MustCompile(`<[^>]*>`) + re := regexp.MustCompile(`(?s)<[^>]*>`) return re.ReplaceAllString(s, "") } -func FilterArtist(data map[string]interface{}) map[string]interface{} { +func FilterArtist(data map[string]interface{}, separator string) map[string]interface{} { dataMap := getMap(data, "data") artistData := getMap(dataMap, "artistUnion") if len(artistData) == 0 { @@ -1424,7 +1412,7 @@ func FilterArtist(data map[string]interface{}) map[string]interface{} { return filtered } -func FilterSearch(data map[string]interface{}) map[string]interface{} { +func FilterSearch(data map[string]interface{}, separator string) map[string]interface{} { dataMap := getMap(data, "data") searchData := getMap(dataMap, "searchV2") if len(searchData) == 0 { @@ -1514,7 +1502,7 @@ func FilterSearch(data map[string]interface{}) map[string]interface{} { for _, artist := range trackArtists { trackArtistNames = append(trackArtistNames, getString(artist, "name")) } - trackArtistsString := strings.Join(trackArtistNames, GetSeparator()) + trackArtistsString := strings.Join(trackArtistNames, separator) durationString := getString(trackDuration, "formatted") @@ -1586,7 +1574,7 @@ func FilterSearch(data map[string]interface{}) map[string]interface{} { for _, artist := range albumArtists { albumArtistNames = append(albumArtistNames, getString(artist, "name")) } - albumArtistsString := strings.Join(albumArtistNames, GetSeparator()) + albumArtistsString := strings.Join(albumArtistNames, separator) dateInfo := getMap(album, "date") var year interface{} diff --git a/backend/spotfetch_api.go b/backend/spotfetch_api.go index cce1a93..db484c0 100644 --- a/backend/spotfetch_api.go +++ b/backend/spotfetch_api.go @@ -11,10 +11,37 @@ import ( "time" ) -func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool, apiBaseURL string, batch bool, delay time.Duration) (interface{}, error) { - if !useAPI || apiBaseURL == "" { +func streamTrackListChunks(ctx context.Context, tracks []AlbumTrackMetadata, callback MetadataCallback) error { + if callback == nil || len(tracks) == 0 { + return nil + } - return GetFilteredSpotifyData(ctx, spotifyURL, batch, delay) + const chunkSize = 25 + for start := 0; start < len(tracks); start += chunkSize { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + end := start + chunkSize + if end > len(tracks) { + end = len(tracks) + } + + callback(tracks[start:end]) + + if end < len(tracks) { + time.Sleep(15 * time.Millisecond) + } + } + + return nil +} + +func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool, apiBaseURL string, batch bool, delay time.Duration, separator string, callback MetadataCallback) (interface{}, error) { + if !useAPI || apiBaseURL == "" { + return GetFilteredSpotifyData(ctx, spotifyURL, batch, delay, separator, callback) } spotifyType, id := parseSpotifyURLToTypeAndID(spotifyURL) @@ -22,6 +49,10 @@ func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool, return nil, fmt.Errorf("invalid Spotify URL: %s", spotifyURL) } + if spotifyType == "artist" { + return GetFilteredSpotifyData(ctx, spotifyURL, batch, delay, separator, callback) + } + apiURL := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(apiBaseURL, "/"), spotifyType, id) req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) @@ -63,22 +94,75 @@ func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool, return nil, fmt.Errorf("failed to decode album response: %w", err) } data = &albumResp + if callback != nil { + callback(&AlbumResponsePayload{ + AlbumInfo: albumResp.AlbumInfo, + TrackList: []AlbumTrackMetadata{}, + }) + if err := streamTrackListChunks(ctx, albumResp.TrackList, callback); err != nil { + return nil, err + } + } case "playlist": var playlistResp PlaylistResponsePayload if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil { return nil, fmt.Errorf("failed to decode playlist response: %w", err) } data = playlistResp + if callback != nil { + callback(PlaylistResponsePayload{ + PlaylistInfo: playlistResp.PlaylistInfo, + TrackList: []AlbumTrackMetadata{}, + }) + if err := streamTrackListChunks(ctx, playlistResp.TrackList, callback); err != nil { + return nil, err + } + } case "artist": var artistResp ArtistDiscographyPayload if err := json.Unmarshal(bodyBytes, &artistResp); err != nil { return nil, fmt.Errorf("failed to decode artist response: %w", err) } data = &artistResp + if callback != nil { + callback(&ArtistDiscographyPayload{ + ArtistInfo: artistResp.ArtistInfo, + AlbumList: artistResp.AlbumList, + TrackList: []AlbumTrackMetadata{}, + }) + if err := streamTrackListChunks(ctx, artistResp.TrackList, callback); err != nil { + return nil, err + } + } default: return nil, fmt.Errorf("unsupported Spotify type: %s", spotifyType) } + if callback != nil { + switch payload := data.(type) { + case TrackResponse: + t := payload.Track + callback([]AlbumTrackMetadata{{ + SpotifyID: t.SpotifyID, + Artists: t.Artists, + Name: t.Name, + AlbumName: t.AlbumName, + AlbumArtist: t.AlbumArtist, + DurationMS: t.DurationMS, + Images: t.Images, + ReleaseDate: t.ReleaseDate, + TrackNumber: t.TrackNumber, + TotalTracks: t.TotalTracks, + DiscNumber: t.DiscNumber, + TotalDiscs: t.TotalDiscs, + ExternalURL: t.ExternalURL, + Plays: t.Plays, + PreviewURL: t.PreviewURL, + IsExplicit: t.IsExplicit, + }}) + } + } + return data, nil } diff --git a/backend/spotify_metadata.go b/backend/spotify_metadata.go index ef296d8..3d28633 100644 --- a/backend/spotify_metadata.go +++ b/backend/spotify_metadata.go @@ -18,13 +18,17 @@ var ( errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL") ) +type MetadataCallback func(data interface{}) + type SpotifyMetadataClient struct { httpClient *http.Client + Separator string } func NewSpotifyMetadataClient() *SpotifyMetadataClient { return &SpotifyMetadataClient{ httpClient: &http.Client{Timeout: 30 * time.Second}, + Separator: ", ", } } @@ -342,54 +346,57 @@ type SearchResponse struct { Playlists []SearchResult `json:"playlists"` } -func GetFilteredSpotifyData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) { +func GetFilteredSpotifyData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration, separator string, callback MetadataCallback) (interface{}, error) { client := NewSpotifyMetadataClient() - return client.GetFilteredData(ctx, spotifyURL, batch, delay) + if separator != "" { + client.Separator = separator + } + return client.GetFilteredData(ctx, spotifyURL, batch, delay, callback) } -func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) { +func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration, callback MetadataCallback) (interface{}, error) { parsed, err := parseSpotifyURI(spotifyURL) if err != nil { return nil, err } - raw, err := c.getRawSpotifyData(ctx, parsed, batch, delay) + raw, err := c.getRawSpotifyData(ctx, parsed, batch, delay, callback) if err != nil { return nil, err } - return c.processSpotifyData(ctx, raw) + return c.processSpotifyData(ctx, raw, callback) } -func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed spotifyURI, batch bool, delay time.Duration) (interface{}, error) { +func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed spotifyURI, batch bool, delay time.Duration, callback MetadataCallback) (interface{}, error) { switch parsed.Type { case "playlist": - return c.fetchPlaylist(ctx, parsed.ID) + return c.fetchPlaylist(ctx, parsed.ID, callback) case "album": - return c.fetchAlbum(ctx, parsed.ID) + return c.fetchAlbum(ctx, parsed.ID, callback) case "track": return c.fetchTrack(ctx, parsed.ID) case "artist_discography": - return c.fetchArtistDiscography(ctx, parsed) + return c.fetchArtistDiscography(ctx, parsed, callback) case "artist": discographyParsed := spotifyURI{Type: "artist_discography", ID: parsed.ID, DiscographyGroup: "all"} - return c.fetchArtistDiscography(ctx, discographyParsed) + return c.fetchArtistDiscography(ctx, discographyParsed, callback) default: return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type) } } -func (c *SpotifyMetadataClient) processSpotifyData(ctx context.Context, raw interface{}) (interface{}, error) { +func (c *SpotifyMetadataClient) processSpotifyData(ctx context.Context, raw interface{}, callback MetadataCallback) (interface{}, error) { switch payload := raw.(type) { case *apiPlaylistResponse: - return c.formatPlaylistData(payload), nil + return c.formatPlaylistData(payload, callback), nil case *apiAlbumResponse: - return c.formatAlbumData(payload) + return c.formatAlbumData(payload, callback) case *apiTrackResponse: return c.formatTrackData(payload), nil case *apiArtistResponse: - return c.formatArtistDiscographyData(ctx, payload) + return c.formatArtistDiscographyData(ctx, payload, callback) default: return nil, errors.New("unknown raw payload type") } @@ -437,7 +444,7 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string) if albumID != "" { - albumResponse, err := c.fetchAlbumWithClient(ctx, client, albumID) + albumResponse, err := c.fetchAlbumWithClient(ctx, client, albumID, nil) if err == nil && albumResponse != nil { albumJSON, _ := json.Marshal(albumResponse) @@ -482,7 +489,7 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string) } } - filteredData := FilterTrack(data, albumFetchData) + filteredData := FilterTrack(data, c.Separator, albumFetchData) jsonData, err := json.Marshal(filteredData) if err != nil { @@ -497,15 +504,15 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string) return &result, nil } -func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID string) (*apiAlbumResponse, error) { +func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID string, callback MetadataCallback) (*apiAlbumResponse, error) { client := NewSpotifyClient() if err := client.Initialize(); err != nil { return nil, fmt.Errorf("failed to initialize spotify client: %w", err) } - return c.fetchAlbumWithClient(ctx, client, albumID) + return c.fetchAlbumWithClient(ctx, client, albumID, callback) } -func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client *SpotifyClient, albumID string) (*apiAlbumResponse, error) { +func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client *SpotifyClient, albumID string, callback MetadataCallback) (*apiAlbumResponse, error) { allItems := []interface{}{} offset := 0 @@ -537,6 +544,15 @@ func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client if data == nil { data = response + if callback != nil { + filtered := FilterAlbum(data, c.Separator) + jsonData, _ := json.Marshal(filtered) + var result apiAlbumResponse + if json.Unmarshal(jsonData, &result) == nil { + formatted, _ := c.formatAlbumData(&result, nil) + callback(formatted) + } + } } albumData := getMap(getMap(response, "data"), "albumUnion") @@ -579,7 +595,7 @@ func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client tracksV2["totalCount"] = len(allItems) } - filteredData := FilterAlbum(data) + filteredData := FilterAlbum(data, c.Separator) jsonData, err := json.Marshal(filteredData) if err != nil { @@ -594,7 +610,7 @@ func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client return &result, nil } -func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID string) (*apiPlaylistResponse, error) { +func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID string, callback MetadataCallback) (*apiPlaylistResponse, error) { client := NewSpotifyClient() if err := client.Initialize(); err != nil { return nil, fmt.Errorf("failed to initialize spotify client: %w", err) @@ -630,6 +646,15 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID st if data == nil { data = response + if callback != nil { + filtered := FilterPlaylist(data, c.Separator) + jsonData, _ := json.Marshal(filtered) + var result apiPlaylistResponse + if json.Unmarshal(jsonData, &result) == nil { + formatted := c.formatPlaylistData(&result, nil) + callback(formatted) + } + } } playlistData := getMap(getMap(response, "data"), "playlistV2") @@ -672,7 +697,7 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID st content["totalCount"] = len(allItems) } - filteredData := FilterPlaylist(data) + filteredData := FilterPlaylist(data, c.Separator) jsonData, err := json.Marshal(filteredData) if err != nil { @@ -687,7 +712,7 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID st return &result, nil } -func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, parsed spotifyURI) (*apiArtistResponse, error) { +func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, parsed spotifyURI, callback MetadataCallback) (*apiArtistResponse, error) { client := NewSpotifyClient() if err := client.Initialize(); err != nil { return nil, fmt.Errorf("failed to initialize spotify client: %w", err) @@ -712,6 +737,16 @@ func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, pars return nil, fmt.Errorf("failed to query artist overview: %w", err) } + if callback != nil { + filtered := FilterArtist(data, c.Separator) + jsonData, _ := json.Marshal(filtered) + var result apiArtistResponse + if json.Unmarshal(jsonData, &result) == nil { + formatted, _ := c.formatArtistDiscographyData(ctx, &result, nil) + callback(formatted) + } + } + allDiscographyItems := []interface{}{} offset := 0 limit := 50 @@ -841,7 +876,7 @@ func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, pars } } - filteredData := FilterArtist(data) + filteredData := FilterArtist(data, c.Separator) jsonData, err := json.Marshal(filteredData) if err != nil { @@ -898,7 +933,7 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp } } -func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumResponsePayload, error) { +func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse, callback MetadataCallback) (*AlbumResponsePayload, error) { var artistID, artistURL string info := AlbumInfoMetadata{ @@ -911,6 +946,13 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumRe ArtistURL: artistURL, } + if callback != nil { + callback(AlbumResponsePayload{ + AlbumInfo: info, + TrackList: []AlbumTrackMetadata{}, + }) + } + tracks := make([]AlbumTrackMetadata, 0, len(raw.Tracks)) for idx, item := range raw.Tracks { durationMS := parseDuration(item.Duration) @@ -955,13 +997,17 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumRe }) } + if callback != nil { + callback(tracks) + } + return &AlbumResponsePayload{ AlbumInfo: info, TrackList: tracks, }, nil } -func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) PlaylistResponsePayload { +func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse, callback MetadataCallback) PlaylistResponsePayload { var info PlaylistInfoMetadata info.Tracks.Total = raw.Count info.Followers.Total = raw.Followers @@ -971,6 +1017,13 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla info.Cover = raw.Cover info.Description = raw.Description + if callback != nil { + callback(PlaylistResponsePayload{ + PlaylistInfo: info, + TrackList: []AlbumTrackMetadata{}, + }) + } + tracks := make([]AlbumTrackMetadata, 0, len(raw.Tracks)) for _, item := range raw.Tracks { durationMS := parseDuration(item.Duration) @@ -1015,13 +1068,17 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla }) } + if callback != nil { + callback(tracks) + } + return PlaylistResponsePayload{ PlaylistInfo: info, TrackList: tracks, } } -func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, raw *apiArtistResponse) (*ArtistDiscographyPayload, error) { +func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, raw *apiArtistResponse, callback MetadataCallback) (*ArtistDiscographyPayload, error) { discType := "all" info := ArtistInfoMetadata{ @@ -1067,7 +1124,17 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, Images: alb.Cover, ExternalURL: fmt.Sprintf("https://open.spotify.com/album/%s", alb.ID), }) + } + if callback != nil { + callback(ArtistDiscographyPayload{ + ArtistInfo: info, + AlbumList: albumList, + TrackList: []AlbumTrackMetadata{}, + }) + } + + for _, alb := range raw.Discography.All { go func(albumID string, albumName string) { sem <- struct{}{} @@ -1081,7 +1148,7 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, default: } - albumData, err := c.fetchAlbumWithClient(ctx, sharedClient, albumID) + albumData, err := c.fetchAlbumWithClient(ctx, sharedClient, albumID, nil) if err != nil { fmt.Printf("Error getting tracks for album %s: %v\n", albumName, err) resultsChan <- fetchResult{tracks: []AlbumTrackMetadata{}} @@ -1131,6 +1198,9 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, IsExplicit: tr.IsExplicit, }) } + if callback != nil { + callback(tracks) + } resultsChan <- fetchResult{tracks: tracks} }(alb.ID, alb.Name) } @@ -1290,7 +1360,7 @@ func (c *SpotifyMetadataClient) Search(ctx context.Context, query string, limit return nil, fmt.Errorf("failed to query search: %w", err) } - filteredData := FilterSearch(data) + filteredData := FilterSearch(data, c.Separator) jsonData, err := json.Marshal(filteredData) if err != nil { @@ -1407,7 +1477,7 @@ func (c *SpotifyMetadataClient) SearchByType(ctx context.Context, query string, return nil, fmt.Errorf("failed to query search: %w", err) } - filteredData := FilterSearch(data) + filteredData := FilterSearch(data, c.Separator) jsonData, err := json.Marshal(filteredData) if err != nil { diff --git a/backend/tidal.go b/backend/tidal.go index cf0aae2..630ee7b 100644 --- a/backend/tidal.go +++ b/backend/tidal.go @@ -8,7 +8,6 @@ import ( "io" "math/rand" "net/http" - "net/url" "os" "os/exec" "path/filepath" @@ -91,47 +90,17 @@ func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) { } func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) { - - spotifyBase := "https://open.spotify.com/track/" - spotifyURL := fmt.Sprintf("%s%s", spotifyBase, spotifyTrackID) - - apiBase := "https://api.song.link/v1-alpha.1/links?url=" - apiURL := fmt.Sprintf("%s%s", apiBase, 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", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") - fmt.Println("Getting Tidal URL...") - - resp, err := t.client.Do(req) + client := NewSongLinkClient() + urls, err := client.GetAllURLsFromSpotify(spotifyTrackID, "") if err != nil { return "", fmt.Errorf("failed to get Tidal URL: %w", err) } - defer resp.Body.Close() - if resp.StatusCode != 200 { - return "", fmt.Errorf("API returned status %d", resp.StatusCode) - } - - var songLinkResp struct { - LinksByPlatform map[string]struct { - URL string `json:"url"` - } `json:"linksByPlatform"` - } - if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil { - return "", fmt.Errorf("failed to decode response: %w", err) - } - - tidalLink, ok := songLinkResp.LinksByPlatform["tidal"] - if !ok || tidalLink.URL == "" { + tidalURL := urls.TidalURL + if tidalURL == "" { return "", fmt.Errorf("tidal link not found") } - - tidalURL := tidalLink.URL fmt.Printf("Found Tidal URL: %s\n", tidalURL) return tidalURL, nil } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b43315d..104fb33 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ import { DownloadQueue } from "@/components/DownloadQueue"; import { DownloadProgressToast } from "@/components/DownloadProgressToast"; import { AudioAnalysisPage } from "@/components/AudioAnalysisPage"; import { AudioConverterPage } from "@/components/AudioConverterPage"; +import { AudioResamplerPage } from "@/components/AudioResamplerPage"; import { FileManagerPage } from "@/components/FileManagerPage"; import { SettingsPage } from "@/components/SettingsPage"; import { DebugLoggerPage } from "@/components/DebugLoggerPage"; @@ -35,6 +36,72 @@ import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog"; import { useDownloadProgress } from "@/hooks/useDownloadProgress"; const HISTORY_KEY = "spotiflac_fetch_history"; const MAX_HISTORY = 5; +function extractSpotifyEntityFromURL(url: string): { + type: string; + id: string; +} | null { + const trimmed = url.trim(); + if (!trimmed) { + return null; + } + const spotifyUriMatch = trimmed.match(/^spotify:(track|album|playlist|artist):([A-Za-z0-9]+)$/i); + if (spotifyUriMatch) { + return { + type: spotifyUriMatch[1].toLowerCase(), + id: spotifyUriMatch[2], + }; + } + try { + const parsed = new URL(trimmed); + const segments = parsed.pathname.split("/").filter(Boolean); + const supportedTypes = new Set(["track", "album", "playlist", "artist"]); + for (let i = 0; i < segments.length - 1; i++) { + const segment = segments[i].toLowerCase(); + if (!supportedTypes.has(segment)) { + continue; + } + const id = segments[i + 1]; + if (id) { + return { type: segment, id }; + } + } + } + catch { + } + return null; +} +function normalizeHistoryURL(url: string): string { + const trimmed = url.trim(); + if (!trimmed) + return trimmed; + const withoutQuery = trimmed.split("?")[0].replace(/\/+$/, ""); + const spotifyEntity = extractSpotifyEntityFromURL(withoutQuery); + if (spotifyEntity) { + return `https://open.spotify.com/${spotifyEntity.type}/${spotifyEntity.id}`; + } + return withoutQuery.replace(/(\/artist\/[A-Za-z0-9]+)\/discography\/all$/i, "$1"); +} +function getHistoryIdentityKey(type: HistoryItem["type"], url: string): string { + const normalizedUrl = normalizeHistoryURL(url); + const spotifyEntity = extractSpotifyEntityFromURL(normalizedUrl); + if (spotifyEntity) { + return `${type}:${spotifyEntity.id}`; + } + return `${type}:${normalizedUrl}`; +} +function dedupeHistoryItems(items: HistoryItem[]): HistoryItem[] { + const seen = new Set(); + const deduped: HistoryItem[] = []; + for (const item of items) { + const normalizedUrl = normalizeHistoryURL(item.url); + const key = getHistoryIdentityKey(item.type, normalizedUrl); + if (seen.has(key)) + continue; + seen.add(key); + deduped.push({ ...item, url: normalizedUrl }); + } + return deduped; +} function App() { const [currentPage, setCurrentPage] = useState("main"); const [spotifyUrl, setSpotifyUrl] = useState(""); @@ -166,7 +233,9 @@ function App() { try { const saved = localStorage.getItem(HISTORY_KEY); if (saved) { - setFetchHistory(JSON.parse(saved)); + const deduped = dedupeHistoryItems(JSON.parse(saved)); + setFetchHistory(deduped); + localStorage.setItem(HISTORY_KEY, JSON.stringify(deduped)); } } catch (err) { @@ -221,9 +290,12 @@ function App() { }; const addToHistory = (item: Omit) => { setFetchHistory((prev) => { - const filtered = prev.filter((h) => h.url !== item.url); + const normalizedUrl = normalizeHistoryURL(item.url); + const identityKey = getHistoryIdentityKey(item.type, normalizedUrl); + const filtered = prev.filter((h) => getHistoryIdentityKey(h.type, h.url) !== identityKey); const newItem: HistoryItem = { ...item, + url: normalizedUrl, id: crypto.randomUUID(), timestamp: Date.now(), }; @@ -344,6 +416,8 @@ function App() { if ("album_info" in metadata.metadata) { const { album_info, track_list } = metadata.metadata; return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => { + const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all"; + setSpotifyUrl(pendingArtistUrl); const artistUrl = await metadata.handleArtistClick(artist); if (artistUrl) { setSpotifyUrl(artistUrl); @@ -358,6 +432,8 @@ function App() { if ("playlist_info" in metadata.metadata) { const { playlist_info, track_list } = metadata.metadata; return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.owner.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlist_info.owner.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => { + const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all"; + setSpotifyUrl(pendingArtistUrl); const artistUrl = await metadata.handleArtistClick(artist); if (artistUrl) { setSpotifyUrl(artistUrl); @@ -372,6 +448,8 @@ function App() { if ("artist_info" in metadata.metadata) { const { artist_info, album_list, track_list } = metadata.metadata; return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => { + const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all"; + setSpotifyUrl(pendingArtistUrl); const artistUrl = await metadata.handleArtistClick(artist); if (artistUrl) { setSpotifyUrl(artistUrl); @@ -428,6 +506,8 @@ function App() { return ; case "audio-converter": return ; + case "audio-resampler": + return ; case "file-manager": return ; default: @@ -456,6 +536,10 @@ function App() { Cancel )} - {brewPath ? ( - - - ) : ( - ) : ( - )} + )} diff --git a/frontend/src/assets/x.webp b/frontend/src/assets/x.webp new file mode 100644 index 0000000..44059a7 Binary files /dev/null and b/frontend/src/assets/x.webp differ diff --git a/frontend/src/components/AboutPage.tsx b/frontend/src/components/AboutPage.tsx index 3ce9ee1..bb374eb 100644 --- a/frontend/src/components/AboutPage.tsx +++ b/frontend/src/components/AboutPage.tsx @@ -2,9 +2,10 @@ import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { openExternal } from "@/lib/utils"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; -import { Star, GitFork, Clock, Download, Blocks, Heart, Copy, CircleCheck } from "lucide-react"; +import { Star, GitFork, Clock, Download, Blocks, Heart, Copy, CircleCheck, Info } from "lucide-react"; import AudioTTSProIcon from "@/assets/audiotts-pro.webp"; import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp"; +import XIcon from "@/assets/x.webp"; import XProIcon from "@/assets/x-pro.webp"; import SpotubeDLIcon from "@/assets/icons/spotubedl.svg"; import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg"; @@ -20,7 +21,7 @@ export function AboutPage() { const [copiedUsdt, setCopiedUsdt] = useState(false); useEffect(() => { const fetchRepoStats = async () => { - const CACHE_KEY = "github_repo_stats"; + const CACHE_KEY = "github_repo_stats_v3"; const CACHE_DURATION = 1000 * 60 * 60; const cached = localStorage.getItem(CACHE_KEY); if (cached) { @@ -55,10 +56,10 @@ export function AboutPage() { } return; } - if (repoRes.ok && releasesRes.ok && langsRes.ok) { + if (repoRes.ok) { const repoData = await repoRes.json(); - const releases = await releasesRes.json(); - const languages = await langsRes.json(); + const releases = releasesRes.ok ? await releasesRes.json() : []; + const languages = langsRes.ok ? await langsRes.json() : {}; let totalDownloads = 0; let latestDownloads = 0; let latestVersion = ""; @@ -79,6 +80,7 @@ export function AboutPage() { stars: repoData.stargazers_count, forks: repoData.forks_count, createdAt: repoData.created_at, + description: repoData.description, totalDownloads, latestDownloads, latestVersion, @@ -128,6 +130,9 @@ export function AboutPage() { const getLangColor = (lang: string): string => { return langColors[lang] || "#858585"; }; + const getRepoDescription = (repoName: string): string => { + return repoStats[repoName]?.description || ""; + }; return (

About

@@ -150,12 +155,13 @@ export function AboutPage() { {activeTab === "projects" && (
- openExternal("https://exyezed.cc/")}> - + openExternal("https://exyezed.qzz.io/")}> + Browser Extensions & Scripts AudioTTS Pro ChatGPT TTS + X X Pro @@ -185,7 +191,7 @@ export function AboutPage() { SpotiDownloader - Get Spotify tracks in MP3 and FLAC via spotidownloader.com + {getRepoDescription("SpotiDownloader")} {repoStats["SpotiDownloader"] && ( @@ -223,7 +229,7 @@ export function AboutPage() {
)} - openExternal("https://github.com/spotiverse/SpotiFLAC-Next")}> + openExternal("https://github.com/spotiverse/SpotiFLAC-Next")}>
SpotiFLAC Next @@ -235,18 +241,18 @@ export function AboutPage() { SpotiFLAC Next -Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required. + {getRepoDescription("SpotiFLAC-Next")} - {repoStats["SpotiFLAC-Next"] && ( -
- {repoStats["SpotiFLAC-Next"].languages?.map((lang: string) => ( + {repoStats["SpotiFLAC-Next"] && ( + {repoStats["SpotiFLAC-Next"].languages?.length > 0 && (
+ {repoStats["SpotiFLAC-Next"].languages.map((lang: string) => ( {lang} ))} -
+
)}
{" "} @@ -261,15 +267,15 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account {formatTimeAgo(repoStats["SpotiFLAC-Next"].createdAt)}
-
- - TOTAL:{" "} - {formatNumber(repoStats["SpotiFLAC-Next"].totalDownloads)} - - - LATEST:{" "} - {formatNumber(repoStats["SpotiFLAC-Next"].latestDownloads)} - +
+
+ + Note +
+

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

)} @@ -285,8 +291,7 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account Twitter/X Media Batch Downloader - A GUI tool to download original-quality images and videos - from Twitter/X accounts, powered by gallery-dl by @mikf + {getRepoDescription("Twitter-X-Media-Batch-Downloader")} {repoStats["Twitter-X-Media-Batch-Downloader"] && ( @@ -345,7 +350,7 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
diff --git a/frontend/src/components/AlbumInfo.tsx b/frontend/src/components/AlbumInfo.tsx index 0de7380..cb6708e 100644 --- a/frontend/src/components/AlbumInfo.tsx +++ b/frontend/src/components/AlbumInfo.tsx @@ -122,7 +122,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT if (response.already_exists) toast.info("Cover already exists"); else - toast.success("Album cover downloaded"); + toast.success("Separate album cover downloaded"); } else { toast.error(response.error || "Failed to download cover"); @@ -153,7 +153,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT {downloadingAlbumCover ? : } -

Download Album Cover

+

Download Separate Album Cover

)} @@ -203,7 +203,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT -

Download All Covers

+

Download All Separate Covers

)} {downloadedTracks.size > 0 && (
- {albumList.length} {albumList.length === 1 ? "album" : "albums"} + {displayedAlbumCount.toLocaleString()} {displayedAlbumCount === 1 ? "album" : "albums"} - {trackList.length} {trackList.length === 1 ? "track" : "tracks"} + {trackList.length.toLocaleString()} {trackList.length === 1 ? "track" : "tracks"} {artistInfo.genres.length > 0 && (<> {artistInfo.genres.join(", ")} @@ -383,9 +420,9 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort )}
- {albumList.length} {albumList.length === 1 ? "album" : "albums"} + {displayedAlbumCount.toLocaleString()} {displayedAlbumCount === 1 ? "album" : "albums"} - {trackList.length} {trackList.length === 1 ? "track" : "tracks"} + {trackList.length.toLocaleString()} {trackList.length === 1 ? "track" : "tracks"} {artistInfo.genres.length > 0 && (<> {artistInfo.genres.join(", ")} @@ -412,7 +449,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort {activeTab === "gallery" && hasGallery && (
-

Gallery ({artistInfo.gallery!.length})

+

Gallery ({artistInfo.gallery!.length.toLocaleString()})

)}
+ {albumFilters.length > 1 && (
+ {albumFilters.map((filter) => ())} +
)}
- {albumList.map((album) => { + {filteredAlbums.map((album) => { const albumTracks = trackList.filter(t => t.album_name === album.name); const tracksWithId = albumTracks.filter(t => t.spotify_id); const isSelected = tracksWithId.length > 0 && tracksWithId.every(t => selectedTracks.includes(t.spotify_id!)); @@ -493,6 +535,9 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
); })}
+ {filteredAlbums.length === 0 && (
+ No releases found for the selected discography filter. +
)}
)} {activeTab === "tracks" && trackList.length > 0 && (
@@ -562,7 +607,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort -

Download All Covers

+

Download All Separate Covers

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

Audio Quality Analysis

+

+ Inspect spectral content and effective quality of FLAC, MP3, M4A, and AAC files +

+
+ {onAnalyze && ()} +
+
+ ); } if (!result) { return null; @@ -46,7 +46,7 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton const formatDuration = (seconds: number) => { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); - return `${mins}:${secs.toString().padStart(2, '0')}`; + return `${mins}:${secs.toString().padStart(2, "0")}`; }; const formatNumber = (num: number) => { return num.toFixed(2); @@ -60,66 +60,120 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; }; const nyquistFreq = result.sample_rate / 2; + const totalSamplesText = result.total_samples > 0 ? result.total_samples.toLocaleString() : "N/A"; + const freqResolutionLabel = result.file_type === "MP3" ? "Freq Res.:" : "Freq Resolution:"; + const hasCodecMeta = result.file_type === "MP3" && (Boolean(result.codec_mode) || + typeof result.bitrate_kbps === "number" || + typeof result.total_frames === "number" || + Boolean(result.codec_version)); return ( - - {filePath && (

{filePath}

)} -
+ + {filePath && (

{filePath}

)} +
- - -
-
- - Sample Rate: - {(result.sample_rate / 1000).toFixed(1)} kHz -
-
- - Bit Depth: - {result.bit_depth} -
-
- - Channels: - {result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels}`} -
-
- - Duration: - {formatDuration(result.duration)} -
-
- - Nyquist: - {(nyquistFreq / 1000).toFixed(1)} kHz -
- {result.file_size > 0 && (
- - Size: - {formatFileSize(result.file_size)} -
)} -
+ +
+
+

Format

+
    + {result.file_type && (
  • + Type: + {result.file_type} +
  • )} +
  • + Sample Rate: + {(result.sample_rate / 1000).toFixed(1)} kHz +
  • +
  • + Bit Depth: + {result.bit_depth} +
  • +
  • + Channels: + {result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels}`} +
  • +
  • + Duration: + {formatDuration(result.duration)} +
  • + {result.file_size > 0 && (
  • + Size: + {formatFileSize(result.file_size)} +
  • )} +
+
- -
-
- - Dynamic Range: - {formatNumber(result.dynamic_range)} dB -
-
- Peak: - {formatNumber(result.peak_amplitude)} dB -
-
- RMS: - {formatNumber(result.rms_level)} dB -
-
- Samples: - {result.total_samples.toLocaleString()} -
-
- - ); +
+

Signal Analytics

+
    +
  • + Nyquist: + {(nyquistFreq / 1000).toFixed(1)} kHz +
  • +
  • + Dynamic Range: + {formatNumber(result.dynamic_range)} dB +
  • +
  • + Peak Amplitude: + {formatNumber(result.peak_amplitude)} dB +
  • +
  • + RMS Level: + {formatNumber(result.rms_level)} dB +
  • +
  • + Total Samples: + {totalSamplesText} +
  • +
+
+ + {hasCodecMeta && (
+

MP3 Meta

+
    + {result.codec_mode && (
  • + Mode: + {result.codec_mode} +
  • )} + {typeof result.bitrate_kbps === "number" && (
  • + Bitrate: + {result.bitrate_kbps} kbps +
  • )} + {typeof result.total_frames === "number" && result.total_frames > 0 && (
  • + Frames: + {result.total_frames.toLocaleString()} +
  • )} + {result.codec_version && (
  • + Version: + {result.codec_version} +
  • )} +
+
)} + + {result.spectrum && (() => { + const frames = result.spectrum.time_slices.length; + const fftSize = (result.spectrum.freq_bins - 1) * 2; + const freqRes = result.sample_rate / fftSize; + return (
+

Spectrum Meta

+
    +
  • + Display Frames: + {frames.toLocaleString()} +
  • +
  • + FFT Size: + {fftSize.toLocaleString()} +
  • +
  • + {freqResolutionLabel} + {freqRes.toFixed(2)} Hz/bin +
  • +
+
); + })()} +
+
+
); } diff --git a/frontend/src/components/AudioAnalysisPage.tsx b/frontend/src/components/AudioAnalysisPage.tsx index 503afb2..962b6b1 100644 --- a/frontend/src/components/AudioAnalysisPage.tsx +++ b/frontend/src/components/AudioAnalysisPage.tsx @@ -1,72 +1,187 @@ -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useRef, useEffect, type ChangeEvent, type DragEvent, type CSSProperties } from "react"; import { Button } from "@/components/ui/button"; -import { Upload, ArrowLeft, Trash2 } from "lucide-react"; +import { Progress } from "@/components/ui/progress"; +import { Upload, ArrowLeft, Trash2, Download } from "lucide-react"; import { AudioAnalysis } from "@/components/AudioAnalysis"; import { SpectrumVisualization } from "@/components/SpectrumVisualization"; import { useAudioAnalysis } from "@/hooks/useAudioAnalysis"; -import { SelectFile } from "../../wailsjs/go/main/App"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; +import { SelectFile, SaveSpectrumImage } from "../../wailsjs/go/main/App"; import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime"; interface AudioAnalysisPageProps { onBack?: () => void; } +const SUPPORTED_AUDIO_EXTENSIONS = [".flac", ".mp3", ".m4a", ".aac"]; +const SUPPORTED_AUDIO_ACCEPT = [ + ".flac", + ".mp3", + ".m4a", + ".aac", + "audio/flac", + "audio/x-flac", + "audio/mpeg", + "audio/mp3", + "audio/mp4", + "audio/x-m4a", + "audio/aac", + "audio/aacp", +].join(","); +const SUPPORTED_AUDIO_LABEL = "FLAC, MP3, M4A, or AAC"; +function isSupportedAudioPath(filePath: string): boolean { + const normalized = filePath.toLowerCase(); + return SUPPORTED_AUDIO_EXTENSIONS.some((ext) => normalized.endsWith(ext)); +} +function isSupportedAudioFile(file: File): boolean { + const normalizedName = file.name.toLowerCase(); + const normalizedType = file.type.toLowerCase(); + return (SUPPORTED_AUDIO_EXTENSIONS.some((ext) => normalizedName.endsWith(ext)) || + normalizedType === "audio/flac" || + normalizedType === "audio/x-flac" || + normalizedType === "audio/mpeg" || + normalizedType === "audio/mp3" || + normalizedType === "audio/mp4" || + normalizedType === "audio/x-m4a" || + normalizedType === "audio/aac" || + normalizedType === "audio/aacp"); +} +function isAbsolutePath(filePath: string): boolean { + return /^(?:[a-zA-Z]:[\\/]|\\\\|\/)/.test(filePath); +} +function fileNameFromPath(filePath: string): string { + const parts = filePath.split(/[/\\]/); + return parts[parts.length - 1] || filePath; +} export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { - const { analyzing, result, analyzeFile, clearResult, selectedFilePath, spectrumLoading } = useAudioAnalysis(); + const { analyzing, analysisProgress, result, analyzeFile, analyzeFilePath, clearResult, selectedFilePath, spectrumLoading, spectrumProgress, reAnalyzeSpectrum, } = useAudioAnalysis(); const [isDragging, setIsDragging] = useState(false); - const handleSelectFile = async () => { + const [isExporting, setIsExporting] = useState(false); + 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`, + }); + 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`, + }); + return; + } + await analyzeFile(file); + }, [analyzeFile]); + const handleSelectFile = useCallback(async () => { try { const filePath = await SelectFile(); - if (filePath) { - await analyzeFile(filePath); + if (!filePath) { + return; } + await analyzeSelectedPath(filePath); } - catch (err) { - toast.error("File Selection Failed", { - description: err instanceof Error ? err.message : "Failed to select file", - }); + catch { + fileInputRef.current?.click(); } - }; - const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => { + }, [analyzeSelectedPath]); + const handleInputChange = useCallback(async (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) + return; + await analyzeSelectedFile(file); + e.target.value = ""; + }, [analyzeSelectedFile]); + const handleHtmlDrop = useCallback(async (e: DragEvent) => { + e.preventDefault(); setIsDragging(false); - if (paths.length === 0) + const file = e.dataTransfer.files?.[0]; + if (!file) return; - const filePath = paths[0]; - if (!filePath.toLowerCase().endsWith(".flac")) { - toast.error("Invalid File Type", { - description: "Please drop a FLAC file for analysis", - }); - return; - } - await analyzeFile(filePath); - }, [analyzeFile]); + await analyzeSelectedFile(file); + }, [analyzeSelectedFile]); useEffect(() => { - OnFileDrop((x, y, paths) => { - handleFileDrop(x, y, paths); + OnFileDrop((_x, _y, paths) => { + setIsDragging(false); + const droppedPath = paths?.[0]; + if (!droppedPath) + return; + void analyzeSelectedPath(droppedPath); }, true); return () => { OnFileDropOff(); }; - }, [handleFileDrop]); + }, [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" }); + return; + } + setIsExporting(true); + try { + if (selectedFilePath && isAbsolutePath(selectedFilePath)) { + const outPath = await SaveSpectrumImage(selectedFilePath, dataUrl); + toast.success("Exported Successfully", { + 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", { + description: "Spectrogram image downloaded", + }); + } + catch (err) { + toast.error("Export Failed", { + description: err instanceof Error ? err.message : "Failed to export image", + }); + } + finally { + setIsExporting(false); + } + }, [selectedFilePath]); const handleAnalyzeAnother = () => { clearResult(); }; + const fileName = selectedFilePath ? fileNameFromPath(selectedFilePath) : undefined; return (
- -
-
- {onBack && ()} -

Audio Quality Analyzer

-
- {result && ()} -
+ - - {!result && !analyzing && (
+
+ {onBack && ()} +

Audio Quality Analyzer

+
+ {result && (
+ + +
)} +
+ + {!result && !analyzing && (
{ e.preventDefault(); @@ -74,40 +189,38 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { }} onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); - }} onDrop={(e) => { - e.preventDefault(); - setIsDragging(false); - }} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}> -
- -
-

- {isDragging - ? "Drop your FLAC file here" - : "Drag and drop a FLAC file here, or click the button below to select"} -

- -
)} + }} onDrop={handleHtmlDrop} style={{ "--wails-drop-target": "drop" } as CSSProperties}> +
+ +
+

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

+ +

+ Supported formats: FLAC, MP3, M4A, AAC +

+
)} - - {analyzing && !result && (
-
-

Analyzing audio file...

-
)} + {analyzing && !result && (
+
+
+ Processing... + {analysisProgress.percent}% +
+ +
+
)} - - {result && (
- - + {result && (
+ - - {spectrumLoading ? (
-
-

Loading spectrum data...

-
) : ()} -
)} -
); + + )} + ); } diff --git a/frontend/src/components/AudioResamplerPage.tsx b/frontend/src/components/AudioResamplerPage.tsx new file mode 100644 index 0000000..55a0bc7 --- /dev/null +++ b/frontend/src/components/AudioResamplerPage.tsx @@ -0,0 +1,468 @@ +import { useState, useCallback, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { Upload, X, CheckCircle2, AlertCircle, Trash2, FileMusic } from "lucide-react"; +import { Spinner } from "@/components/ui/spinner"; +import { SelectAudioFiles, SelectFolder, ListAudioFilesInDir, ResampleAudio } from "../../wailsjs/go/main/App"; +import { toastWithSound as toast } from "@/lib/toast-with-sound"; +import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime"; +import { AudioLinesIcon } from "@/components/ui/audio-lines"; +interface AudioFile { + path: string; + name: string; + format: string; + size: number; + status: "pending" | "resampling" | "success" | "error"; + error?: string; + outputPath?: string; + srcSampleRate?: number; + srcBitDepth?: number; +} +function formatFileSize(bytes: number): string { + if (bytes === 0) + return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; +} +function formatSampleRate(sr: number): string { + if (!sr) + return ""; + if (sr === 44100) + return "44.1kHz"; + if (sr >= 1000) + return `${sr / 1000}kHz`; + return `${sr}Hz`; +} +const SAMPLE_RATE_OPTIONS = [ + { value: "44100", label: "44.1kHz" }, + { value: "48000", label: "48kHz" }, + { value: "96000", label: "96kHz" }, + { value: "192000", label: "192kHz" }, +]; +const BIT_DEPTH_OPTIONS = [ + { value: "16", label: "16-bit" }, + { value: "24", label: "24-bit" }, +]; +const STORAGE_KEY = "spotiflac_audio_resampler_state"; +export function AudioResamplerPage() { + const [files, setFiles] = useState(() => { + try { + const saved = sessionStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed.files && Array.isArray(parsed.files) && parsed.files.length > 0) { + return parsed.files; + } + } + } + catch (err) { + console.error("Failed to load saved state:", err); + } + return []; + }); + const [sampleRate, setSampleRate] = useState(() => { + try { + const saved = sessionStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed.sampleRate) + return parsed.sampleRate; + } + } + catch (err) { + } + return "44100"; + }); + const [bitDepth, setBitDepth] = useState(() => { + try { + const saved = sessionStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed.bitDepth) + return parsed.bitDepth; + } + } + catch (err) { + } + return "16"; + }); + const [resampling, setResampling] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const saveState = useCallback((stateToSave: { + files: AudioFile[]; + sampleRate: string; + bitDepth: string; + }) => { + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave)); + } + catch (err) { + console.error("Failed to save state:", err); + } + }, []); + useEffect(() => { + saveState({ files, sampleRate, bitDepth }); + }, [files, sampleRate, bitDepth, saveState]); + useEffect(() => { + const checkFullscreen = () => { + const isMaximized = window.innerHeight >= window.screen.height * 0.9; + setIsFullscreen(isMaximized); + }; + checkFullscreen(); + window.addEventListener("resize", checkFullscreen); + window.addEventListener("focus", checkFullscreen); + return () => { + window.removeEventListener("resize", checkFullscreen); + window.removeEventListener("focus", checkFullscreen); + }; + }, []); + const fetchAudioInfo = useCallback(async (paths: string[]) => { + if (paths.length === 0) + return; + try { + const GetFlacInfoBatch = (window as any)["go"]["main"]["App"]["GetFlacInfoBatch"]; + const infos: Array<{ + path: string; + sample_rate: number; + bits_per_sample: number; + }> = await GetFlacInfoBatch(paths); + setFiles((prev) => prev.map((f) => { + const info = infos.find((i) => i.path === f.path || i.path.toLowerCase() === f.path.toLowerCase()); + if (info) { + return { + ...f, + srcSampleRate: info.sample_rate || undefined, + srcBitDepth: info.bits_per_sample || undefined, + }; + } + return f; + })); + } + catch (err) { + console.error("Failed to fetch audio info:", err); + } + }, []); + const handleSelectFiles = async () => { + try { + const selectedFiles = await SelectAudioFiles(); + if (selectedFiles && selectedFiles.length > 0) { + addFiles(selectedFiles); + } + } + catch (err) { + toast.error("File Selection Failed", { + description: err instanceof Error ? err.message : "Failed to select files", + }); + } + }; + const handleSelectFolder = async () => { + try { + const selectedFolder = await SelectFolder(""); + if (selectedFolder) { + const folderFiles = await ListAudioFilesInDir(selectedFolder); + if (folderFiles && folderFiles.length > 0) { + addFiles(folderFiles.map((f) => f.path)); + } + else { + toast.info("No audio files found", { + description: "No FLAC files found in the selected folder.", + }); + } + } + } + catch (err) { + toast.error("Folder Selection Failed", { + description: err instanceof Error ? err.message : "Failed to select folder", + }); + } + }; + const addFiles = useCallback(async (paths: string[]) => { + const validExtensions = [".flac"]; + const invalidFiles = paths.filter((path) => { + const ext = path.toLowerCase().slice(path.lastIndexOf(".")); + return !validExtensions.includes(ext); + }); + if (invalidFiles.length > 0) { + toast.error("Unsupported format", { + description: "Only FLAC files are supported for resampling.", + }); + } + const GetFileSizes = (files: string[]): Promise> => (window as any)["go"]["main"]["App"]["GetFileSizes"](files); + const validPaths = paths.filter((path) => { + const ext = path.toLowerCase().slice(path.lastIndexOf(".")); + return validExtensions.includes(ext); + }); + const fileSizes = validPaths.length > 0 ? await GetFileSizes(validPaths) : {}; + let newlyAddedPaths: string[] = []; + setFiles((prev) => { + const newFiles: AudioFile[] = validPaths + .filter((path) => !prev.some((f) => f.path === path)) + .map((path) => { + const name = path.split(/[/\\]/).pop() || path; + const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase(); + return { + path, + name, + format: ext, + size: fileSizes[path] || 0, + status: "pending" as const, + }; + }); + newlyAddedPaths = newFiles.map((f) => f.path); + if (newFiles.length > 0) { + if (paths.length > newFiles.length + invalidFiles.length) { + const skipped = paths.length - newFiles.length - invalidFiles.length; + toast.info("Some files skipped", { + description: `${skipped} file(s) were already added`, + }); + } + return [...prev, ...newFiles]; + } + if (validPaths.length > 0 && newFiles.length === 0) { + toast.info("No new files added", { + description: "All valid files were already added", + }); + } + return prev; + }); + setTimeout(() => { + if (newlyAddedPaths.length > 0) { + fetchAudioInfo(newlyAddedPaths); + } + }, 50); + }, [fetchAudioInfo]); + const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => { + setIsDragging(false); + if (paths.length === 0) + return; + addFiles(paths); + }, [addFiles]); + useEffect(() => { + OnFileDrop((x, y, paths) => { + handleFileDrop(x, y, paths); + }, true); + return () => { + OnFileDropOff(); + }; + }, [handleFileDrop]); + const removeFile = (path: string) => { + setFiles((prev) => prev.filter((f) => f.path !== path)); + }; + const clearFiles = () => { + setFiles([]); + }; + const handleResample = async () => { + if (files.length === 0) { + toast.error("No files selected", { + description: "Please add FLAC files to resample", + }); + return; + } + setResampling(true); + try { + const inputPaths = files.map((f) => f.path); + setFiles((prev) => prev.map((f) => { + if (inputPaths.includes(f.path)) { + return { ...f, status: "resampling" as const, error: undefined }; + } + return f; + })); + const results = await ResampleAudio({ + input_files: inputPaths, + sample_rate: sampleRate, + bit_depth: bitDepth, + }); + setFiles((prev) => prev.map((f) => { + const result = results.find((r: any) => r.input_file === f.path || r.input_file.toLowerCase() === f.path.toLowerCase()); + if (result) { + return { + ...f, + status: result.success ? "success" : "error", + error: result.error, + outputPath: result.output_file, + }; + } + return f; + })); + const successCount = results.filter((r: any) => r.success).length; + const failCount = results.filter((r: any) => !r.success).length; + if (successCount > 0) { + toast.success("Resampling Complete", { + description: `Successfully resampled ${successCount} file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`, + }); + } + else if (failCount > 0) { + toast.error("Resampling Failed", { + description: `All ${failCount} file(s) failed to resample`, + }); + } + } + catch (err) { + toast.error("Resampling Error", { + description: err instanceof Error ? err.message : "Unknown error", + }); + setFiles((prev) => prev.map((f) => ({ ...f, status: "error" as const, error: "Resampling failed" }))); + } + finally { + setResampling(false); + } + }; + const getStatusIcon = (status: AudioFile["status"]) => { + switch (status) { + case "resampling": + return ; + case "success": + return ; + case "error": + return ; + default: + return ; + } + }; + const resampleableCount = files.filter((f) => f.status === "pending" || f.status === "success").length; + const successCount = files.filter((f) => f.status === "success").length; + return (
+ +
+

Audio Resampler

+ {files.length > 0 && (
+ + + +
)} +
+ +
{ + e.preventDefault(); + setIsDragging(true); + }} onDragLeave={(e) => { + e.preventDefault(); + setIsDragging(false); + }} onDrop={(e) => { + e.preventDefault(); + setIsDragging(false); + }} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}> + {files.length === 0 ? (<> +
+ +
+

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

+
+ + +
+

+ Supported format: FLAC +

+ ) : (
+
+
+
+ + { + if (value) + setBitDepth(value); + }}> + {BIT_DEPTH_OPTIONS.map((option) => ( + {option.label} + ))} + +
+ +
+ + { + if (value) + setSampleRate(value); + }}> + {SAMPLE_RATE_OPTIONS.map((option) => ( + {option.label} + ))} + +
+
+
+ +
+
+ {files.length} file(s) • {successCount} resampled +
+
+ +
+ {files.map((file) => { + const srcParts: string[] = []; + if (file.srcBitDepth) + srcParts.push(`${file.srcBitDepth}-bit`); + if (file.srcSampleRate) + srcParts.push(formatSampleRate(file.srcSampleRate)); + const srcSpec = srcParts.join(" / "); + return (
+ {getStatusIcon(file.status)} +
+

{file.name}

+ {file.error && (

+ {file.error} +

)} +
+ + {srcSpec ? ( + {srcSpec} + ) : file.status === "pending" ? ( + reading... + ) : null} + + + {formatFileSize(file.size)} + + + {file.format} + + {file.status !== "resampling" && ()} +
); + })} +
+ +
+ +
+
)} +
+
); +} diff --git a/frontend/src/components/DebugLoggerPage.tsx b/frontend/src/components/DebugLoggerPage.tsx index 4b94fc8..ec463cd 100644 --- a/frontend/src/components/DebugLoggerPage.tsx +++ b/frontend/src/components/DebugLoggerPage.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from "react"; import { Trash2, Copy, Check, FileDown } from "lucide-react"; import { Button } from "@/components/ui/button"; import { logger, type LogEntry } from "@/lib/logger"; +import { useDownloadQueueData } from "@/hooks/useDownloadQueueData"; import { ExportFailedDownloads } from "../../wailsjs/go/main/App"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; const levelColors: Record = { @@ -23,6 +24,13 @@ export function DebugLoggerPage() { const [logs, setLogs] = useState([]); const [copied, setCopied] = useState(false); const scrollRef = useRef(null); + const queueInfo = useDownloadQueueData(); + const hasDownloadActivity = queueInfo.queue.length > 0 || + queueInfo.queued_count > 0 || + queueInfo.completed_count > 0 || + queueInfo.failed_count > 0 || + queueInfo.skipped_count > 0; + const canExportFailed = hasDownloadActivity && queueInfo.failed_count > 0; useEffect(() => { const unsubscribe = logger.subscribe(() => { setLogs(logger.getLogs()); @@ -54,6 +62,9 @@ export function DebugLoggerPage() { } }; const handleExportFailed = async () => { + if (!canExportFailed) { + return; + } try { const message = await ExportFailedDownloads(); if (message.startsWith("Successfully")) { @@ -72,7 +83,7 @@ export function DebugLoggerPage() {

Debug Logs

- diff --git a/frontend/src/components/DownloadProgressToast.tsx b/frontend/src/components/DownloadProgressToast.tsx index 3af9f2a..50ee002 100644 --- a/frontend/src/components/DownloadProgressToast.tsx +++ b/frontend/src/components/DownloadProgressToast.tsx @@ -13,18 +13,18 @@ export function DownloadProgressToast({ onClick }: DownloadProgressToastProps) { return null; } return (
-
); diff --git a/frontend/src/components/HistoryPage.tsx b/frontend/src/components/HistoryPage.tsx index 9f34b4c..41e5ccf 100644 --- a/frontend/src/components/HistoryPage.tsx +++ b/frontend/src/components/HistoryPage.tsx @@ -9,6 +9,8 @@ import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, Pagi import { GetDownloadHistory, ClearDownloadHistory, GetPreviewURL, GetFetchHistory, DeleteDownloadHistoryItem, DeleteFetchHistoryItem, ClearFetchHistoryByType } from "../../wailsjs/go/main/App"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { openExternal } from "@/lib/utils"; +import { SPOTIFY_PREVIEW_VOLUME } from "@/lib/preview"; +import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons"; const formatDate = (timestamp: number) => { const date = new Date(timestamp * 1000); const year = date.getFullYear(); @@ -30,6 +32,7 @@ interface DownloadHistoryItem { quality: string; format: string; path: string; + source: string; timestamp: number; } interface FetchHistoryItem { @@ -62,10 +65,35 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) { const [fetchSearchQuery, setFetchSearchQuery] = useState(""); const [fetchCurrentPage, setFetchCurrentPage] = useState(1); const ITEMS_PER_PAGE = 50; + const getTrackLink = (spotifyId: string) => { + if (spotifyId?.startsWith("tidal_")) + return { url: `https://listen.tidal.com/track/${spotifyId.replace("tidal_", "")}`, label: "Open in Tidal" }; + if (spotifyId?.startsWith("qobuz_")) + return { url: `https://www.qobuz.com/track/${spotifyId.replace("qobuz_", "")}`, label: "Open in Qobuz" }; + if (spotifyId?.startsWith("amazon_")) + return { url: `https://music.amazon.com/tracks/${spotifyId.replace("amazon_", "")}`, label: "Open in Amazon Music" }; + if (spotifyId?.startsWith("deezer_")) + return { url: `https://www.deezer.com/track/${spotifyId.replace("deezer_", "")}`, label: "Open in Deezer" }; + return { url: `https://open.spotify.com/track/${spotifyId}`, label: "Open in Spotify" }; + }; + const getSourceIcon = (source: string) => { + const s = source?.toLowerCase() || ""; + if (s.includes("tidal")) + return ; + if (s.includes("qobuz")) + return ; + if (s.includes("amazon")) + return ; + if (s.includes("deezer")) + return ; + if (s.includes("spotify")) + return ; + return ; + }; const fetchDownloadHistory = async () => { try { const items = await GetDownloadHistory(); - setDownloadHistory(items || []); + setDownloadHistory((items || []) as unknown as DownloadHistoryItem[]); } catch (err) { console.error("Failed to fetch download history:", err); @@ -164,7 +192,7 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) { if (url) { const audio = new Audio(url); audioRef.current = audio; - audio.volume = 0.5; + audio.volume = SPOTIFY_PREVIEW_VOLUME; audio.onended = () => setPlayingPreviewId(null); audio.play(); setPlayingPreviewId(id); @@ -228,8 +256,8 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {

Downloads

- {downloadHistory.length > 0 && ( - {downloadHistory.length.toLocaleString('en-US')} + {filteredDownloadHistory.length > 0 && ( + {filteredDownloadHistory.length.toLocaleString('en-US')} )}
+
+ {getSourceIcon(item.source)} +
-

{playingPreviewId === item.id ? "Pause Preview" : "Play Preview"}

+

{item.source || "Unknown"}

+
+ + +
+ {!(item.spotify_id?.startsWith('tidal_') || item.spotify_id?.startsWith('qobuz_') || item.spotify_id?.startsWith('amazon_') || item.spotify_id?.startsWith('deezer_')) && ( + + + + + +

{playingPreviewId === item.id ? "Pause Preview" : "Play Preview"}

+
+
+
)} - -

Open in Spotify

+

{getTrackLink(item.spotify_id).label}

diff --git a/frontend/src/components/PlaylistInfo.tsx b/frontend/src/components/PlaylistInfo.tsx index f8022b8..5748f2f 100644 --- a/frontend/src/components/PlaylistInfo.tsx +++ b/frontend/src/components/PlaylistInfo.tsx @@ -134,7 +134,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel if (response.already_exists) toast.info("Cover already exists"); else - toast.success("Playlist cover downloaded"); + toast.success("Separate playlist cover downloaded"); } else { toast.error(response.error || "Failed to download cover"); @@ -165,7 +165,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel {downloadingPlaylistCover ? : } -

Download Playlist Cover

+

Download Separate Playlist Cover

)} @@ -213,7 +213,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel -

Download All Covers

+

Download All Separate Covers

)} {downloadedTracks.size > 0 && ( @@ -161,7 +163,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 4f128a6..b6b35e1 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,24 +1,54 @@ +import { useRef, useState, type RefObject } from "react"; import { HomeIcon } from "@/components/ui/home"; import { HistoryIcon } from "@/components/ui/history-icon"; import { SettingsIcon } from "@/components/ui/settings"; -import { ActivityIcon } from "@/components/ui/activity"; +import { ActivityIcon, type ActivityIconHandle } from "@/components/ui/activity"; import { TerminalIcon } from "@/components/ui/terminal"; -import { FileMusicIcon } from "@/components/ui/file-music"; -import { FilePenIcon } from "@/components/ui/file-pen"; +import { FileMusicIcon, type FileMusicIconHandle } from "@/components/ui/file-music"; +import { FilePenIcon, type FilePenIconHandle } from "@/components/ui/file-pen"; import { CoffeeIcon } from "@/components/ui/coffee"; import { BadgeAlertIcon } from "@/components/ui/badge-alert"; import { GithubIcon } from "@/components/ui/github"; import { BlocksIcon } from "@/components/ui/blocks-icon"; +import { AudioLinesIcon, type AudioLinesIconHandle } from "@/components/ui/audio-lines"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Checkbox } from "@/components/ui/checkbox"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Button } from "@/components/ui/button"; import { openExternal } from "@/lib/utils"; -export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager" | "about" | "history"; +export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "about" | "history"; interface SidebarProps { currentPage: PageType; onPageChange: (page: PageType) => void; } +interface AnimatedIconHandle { + startAnimation: () => void; + stopAnimation: () => void; +} export function Sidebar({ currentPage, onPageChange }: SidebarProps) { + const [isIssuesDialogOpen, setIsIssuesDialogOpen] = useState(false); + const [hasIssueAgreement, setHasIssueAgreement] = useState(false); + const analyzerIconRef = useRef(null); + const resamplerIconRef = useRef(null); + const converterIconRef = useRef(null); + const fileManagerIconRef = useRef(null); + const handleIssuesDialogChange = (open: boolean) => { + setIsIssuesDialogOpen(open); + if (!open) { + setHasIssueAgreement(false); + } + }; + const handleOpenIssues = () => { + openExternal("https://github.com/afkarxyz/SpotiFLAC/issues"); + handleIssuesDialogChange(false); + }; + const getAnimatedItemHandlers = (iconRef: RefObject) => ({ + onMouseEnter: () => iconRef.current?.startAnimation(), + onMouseLeave: () => iconRef.current?.stopAnimation(), + onFocus: () => iconRef.current?.startAnimation(), + onBlur: () => iconRef.current?.stopAnimation(), + }); return (
@@ -69,7 +99,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) { - @@ -79,16 +109,20 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) { - onPageChange("audio-analysis")} className="gap-3 cursor-pointer py-2 px-3"> - + onPageChange("audio-analysis")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(analyzerIconRef)}> + Audio Quality Analyzer - onPageChange("audio-converter")} className="gap-3 cursor-pointer py-2 px-3"> - + onPageChange("audio-resampler")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(resamplerIconRef)}> + + Audio Resampler + + onPageChange("audio-converter")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(converterIconRef)}> + Audio Converter - onPageChange("file-manager")} className="gap-3 cursor-pointer py-2 px-3"> - + onPageChange("file-manager")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(fileManagerIconRef)}> + File Manager @@ -96,16 +130,49 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
- - - - - -

Report Bugs or Request Features

-
-
+ + + + + + +

Report Bugs or Request Features

+
+
+ + + Before Opening GitHub Issues + + + +
+
+

Important

+

+ Search existing issues first and use the issue template when opening a new report or request. +

+
+ + +
+ + + + + +
+
diff --git a/frontend/src/components/SpectrumVisualization.tsx b/frontend/src/components/SpectrumVisualization.tsx index 5900ebb..af4e15a 100644 --- a/frontend/src/components/SpectrumVisualization.tsx +++ b/frontend/src/components/SpectrumVisualization.tsx @@ -1,13 +1,463 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from "react"; import type { SpectrumData } from "@/types/api"; +import { Label } from "@/components/ui/label"; +import { Progress } from "@/components/ui/progress"; +import { loadAudioAnalysisPreferences, saveAudioAnalysisPreferences, type AnalyzerColorScheme, type AnalyzerFreqScale, type AnalyzerWindowFunction, } from "@/lib/audio-analysis-preferences"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; +export interface SpectrumVisualizationHandle { + getCanvasDataURL: () => string | null; +} interface SpectrumVisualizationProps { sampleRate: number; - bitsPerSample: number; duration: number; spectrumData?: SpectrumData; + fileName?: string; + onReAnalyze?: (fftSize: number, windowFunction: string) => void; + isAnalyzingSpectrum?: boolean; + spectrumProgress?: { + percent: number; + message: string; + }; } -export function SpectrumVisualization({ sampleRate, bitsPerSample, duration, spectrumData, }: SpectrumVisualizationProps) { +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; +const MAX_RENDER_HEIGHT = 1080; +function clamp01(value: number): number { + return Math.max(0, Math.min(1, value)); +} +function spekColorMap(t: number): [ + number, + number, + number +] { + const colors: Array<[ + number, + number, + number + ]> = [ + [0, 0, 0], + [0, 0, 25], + [0, 0, 50], + [0, 0, 80], + [20, 0, 120], + [50, 0, 150], + [80, 0, 180], + [120, 0, 120], + [150, 0, 80], + [180, 0, 40], + [210, 0, 0], + [240, 30, 0], + [255, 60, 0], + [255, 100, 0], + [255, 140, 0], + [255, 180, 0], + [255, 210, 0], + [255, 235, 0], + [255, 250, 50], + [255, 255, 100], + [255, 255, 150], + [255, 255, 200], + [255, 255, 255], + ]; + const scaled = t * (colors.length - 1); + const idx = Math.floor(scaled); + const fraction = scaled - idx; + if (idx >= colors.length - 1) { + return colors[colors.length - 1]; + } + const c1 = colors[idx]; + const c2 = colors[idx + 1]; + return [ + Math.round(c1[0] + (c2[0] - c1[0]) * fraction), + Math.round(c1[1] + (c2[1] - c1[1]) * fraction), + Math.round(c1[2] + (c2[2] - c1[2]) * fraction), + ]; +} +function viridisColorMap(t: number): [ + number, + number, + number +] { + const colors: Array<[ + number, + number, + number + ]> = [ + [68, 1, 84], + [70, 20, 100], + [72, 40, 120], + [67, 62, 133], + [62, 74, 137], + [55, 89, 140], + [49, 104, 142], + [43, 117, 142], + [38, 130, 142], + [35, 144, 140], + [31, 158, 137], + [42, 171, 129], + [53, 183, 121], + [81, 194, 105], + [109, 205, 89], + [144, 214, 67], + [180, 222, 44], + [216, 227, 41], + [253, 231, 37], + ]; + const scaled = t * (colors.length - 1); + const idx = Math.floor(scaled); + const fraction = scaled - idx; + if (idx >= colors.length - 1) { + return colors[colors.length - 1]; + } + const c1 = colors[idx]; + const c2 = colors[idx + 1]; + return [ + Math.floor(c1[0] + (c2[0] - c1[0]) * fraction), + Math.floor(c1[1] + (c2[1] - c1[1]) * fraction), + Math.floor(c1[2] + (c2[2] - c1[2]) * fraction), + ]; +} +function hotColorMap(t: number): [ + number, + number, + number +] { + if (t < 0.33) { + return [Math.floor(t * 3 * 255), 0, 0]; + } + if (t < 0.66) { + return [255, Math.floor((t - 0.33) * 3 * 255), 0]; + } + return [255, 255, Math.floor((t - 0.66) * 3 * 255)]; +} +function coolColorMap(t: number): [ + number, + number, + number +] { + return [Math.floor(t * 255), Math.floor((1 - t) * 255), 255]; +} +function getColorValues(norm: number, scheme: ColorScheme): [ + number, + number, + number +] { + const value = clamp01(norm); + switch (scheme) { + case "spek": + return spekColorMap(value); + case "viridis": + return viridisColorMap(value); + case "hot": + return hotColorMap(value); + case "cool": + return coolColorMap(value); + case "grayscale": + default: { + const gray = Math.floor(value * 255); + return [gray, gray, gray]; + } + } +} +function getColorString(norm: number, scheme: ColorScheme): string { + const [r, g, b] = getColorValues(norm, scheme); + return `rgb(${r},${g},${b})`; +} +function addAxisLabels(ctx: CanvasRenderingContext2D, plotWidth: number, plotHeight: number, sampleRate: number, duration: number, freqScale: FreqScale, fileName?: string) { + ctx.fillStyle = "#ffffff"; + ctx.font = "12px Segoe UI"; + ctx.textAlign = "center"; + const widthFactor = plotWidth / 1000; + let timeStep: number; + if (duration <= 10) { + timeStep = widthFactor >= 1.8 ? 0.25 : (widthFactor >= 1.3 ? 0.5 : 0.5); + } + else if (duration <= 30) { + timeStep = widthFactor >= 1.8 ? 0.5 : (widthFactor >= 1.3 ? 1 : 1); + } + else if (duration <= 120) { + timeStep = widthFactor >= 1.8 ? 3 : (widthFactor >= 1.3 ? 4 : 5); + } + else if (duration <= 600) { + timeStep = widthFactor >= 1.8 ? 10 : (widthFactor >= 1.3 ? 15 : 20); + } + else { + timeStep = widthFactor >= 1.8 ? 20 : (widthFactor >= 1.3 ? 30 : 40); + } + if (duration > 0) { + for (let time = 0; time <= duration + 1e-9; time += timeStep) { + const timeProgress = time / duration; + const x = MARGIN.left + timeProgress * (plotWidth - 1); + const y = CANVAS_H - MARGIN.bottom + 20; + ctx.strokeStyle = "#ffffff"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, MARGIN.top + plotHeight); + ctx.lineTo(x, MARGIN.top + plotHeight + 5); + ctx.stroke(); + let label: string; + if (timeStep >= 60) { + const minutes = Math.floor(time / 60); + const seconds = time % 60; + label = seconds === 0 ? `${minutes}m` : `${minutes}m${seconds}s`; + } + else { + label = `${time}s`; + } + ctx.fillText(label, x, y); + } + } + ctx.textAlign = "right"; + const maxFreq = sampleRate / 2; + if (freqScale === "log2") { + const heightFactor = plotHeight / 500; + const minFreq = 20; + const frequencies: number[] = []; + const octaveStep = heightFactor >= 1.5 ? 1 : (heightFactor >= 1.0 ? 1 : 2); + let octaveCount = 0; + for (let freq = minFreq; freq <= maxFreq; freq *= 2) { + if (octaveCount % octaveStep === 0) { + frequencies.push(freq); + } + octaveCount++; + } + for (const freq of frequencies) { + const freqNormalized = Math.log2(freq / minFreq) / Math.log2(maxFreq / minFreq); + const y = MARGIN.top + plotHeight * (1 - freqNormalized); + ctx.strokeStyle = "#ffffff"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(MARGIN.left - 5, y); + ctx.lineTo(MARGIN.left, y); + ctx.stroke(); + const label = freq >= 1000 ? `${(freq / 1000).toFixed(1)}k` : `${freq}`; + ctx.fillText(label, MARGIN.left - 10, y + 4); + } + } + else { + const heightFactor = plotHeight / 500; + let freqStep: number; + if (maxFreq <= 8000) { + freqStep = heightFactor >= 1.8 ? 250 : (heightFactor >= 1.3 ? 400 : 500); + } + else if (maxFreq <= 16000) { + freqStep = heightFactor >= 1.8 ? 500 : (heightFactor >= 1.3 ? 800 : 1000); + } + else if (maxFreq <= 24000) { + freqStep = heightFactor >= 1.8 ? 1000 : (heightFactor >= 1.3 ? 1500 : 2000); + } + else { + freqStep = heightFactor >= 1.8 ? 2000 : (heightFactor >= 1.3 ? 2500 : 4000); + } + for (let freq = 0; freq <= maxFreq; freq += freqStep) { + const y = MARGIN.top + plotHeight - (freq / maxFreq) * plotHeight + 4; + const x = MARGIN.left - 15; + ctx.strokeStyle = "#ffffff"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(MARGIN.left - 5, y - 4); + ctx.lineTo(MARGIN.left, y - 4); + ctx.stroke(); + let label: string; + if (freq === 0) { + label = "0"; + } + else if (freq >= 1000) { + label = freq % 1000 === 0 ? `${freq / 1000}k` : `${(freq / 1000).toFixed(1)}k`; + } + else { + label = `${freq}`; + } + ctx.fillText(label, x, y); + } + } + ctx.textAlign = "center"; + ctx.font = "14px Segoe UI"; + ctx.fillText("Time (seconds)", CANVAS_W / 2, CANVAS_H - 15); + ctx.save(); + ctx.translate(25, CANVAS_H / 2); + ctx.rotate(-Math.PI / 2); + ctx.fillText("Frequency (Hz)", 0, 0); + ctx.restore(); + ctx.font = "12px Segoe UI"; + if (fileName) { + ctx.textAlign = "left"; + ctx.fillText(fileName, MARGIN.left + 15, 25); + } + ctx.textAlign = "right"; + ctx.fillText(`Sample Rate: ${sampleRate} Hz`, CANVAS_W - 20, 25); +} +function drawColorBar(ctx: CanvasRenderingContext2D, plotHeight: number, colorScheme: ColorScheme) { + const colorBarWidth = 20; + const colorBarX = CANVAS_W - MARGIN.right + 30; + const colorBarY = MARGIN.top; + const gradient = ctx.createLinearGradient(0, colorBarY + plotHeight, 0, colorBarY); + for (let i = 0; i <= 100; i++) { + const value = i / 100; + gradient.addColorStop(value, getColorString(value, colorScheme)); + } + ctx.fillStyle = gradient; + ctx.fillRect(colorBarX, colorBarY, colorBarWidth, plotHeight); + ctx.strokeStyle = "#ffffff"; + ctx.lineWidth = 1; + ctx.strokeRect(colorBarX, colorBarY, colorBarWidth, plotHeight); + ctx.fillStyle = "#ffffff"; + ctx.font = "10px Segoe UI"; + ctx.textAlign = "left"; + ctx.fillText("High", colorBarX + colorBarWidth + 5, colorBarY + 12); + ctx.fillText("Low", colorBarX + colorBarWidth + 5, colorBarY + plotHeight - 5); +} +async function renderSpectrogram(ctx: CanvasRenderingContext2D, spectrum: SpectrumData, sampleRate: number, duration: number, freqScale: FreqScale, colorScheme: ColorScheme, fileName: string | undefined, shouldCancel: () => boolean) { + const plotWidth = CANVAS_W - MARGIN.left - MARGIN.right; + const plotHeight = CANVAS_H - MARGIN.top - MARGIN.bottom; + ctx.fillStyle = "#000000"; + ctx.fillRect(0, 0, CANVAS_W, CANVAS_H); + const spectrogramData = spectrum.time_slices; + const numTimeFrames = spectrogramData.length; + const numFreqBins = spectrogramData[0]?.magnitudes.length ?? 0; + if (numTimeFrames === 0 || numFreqBins === 0) { + return; + } + let minMag = Number.POSITIVE_INFINITY; + let maxMag = Number.NEGATIVE_INFINITY; + const sampleStep = numTimeFrames > 10000 ? Math.floor(numTimeFrames / 5000) : 1; + for (let i = 0; i < numTimeFrames; i += sampleStep) { + const frame = spectrogramData[i].magnitudes; + for (const mag of frame) { + if (Number.isFinite(mag)) { + if (mag < minMag) + minMag = mag; + if (mag > maxMag) + maxMag = mag; + } + } + } + if (!Number.isFinite(minMag) || !Number.isFinite(maxMag)) { + minMag = -120; + maxMag = 0; + } + const magRange = maxMag - minMag; + const safeMagRange = magRange > 0 ? magRange : 1; + const highResImageData = ctx.createImageData(plotWidth, MAX_RENDER_HEIGHT); + const highResData = highResImageData.data; + const CHUNK_SIZE = 50; + for (let xStart = 0; xStart < plotWidth; xStart += CHUNK_SIZE) { + if (shouldCancel()) { + return; + } + const xEnd = Math.min(xStart + CHUNK_SIZE, plotWidth); + for (let x = xStart; x < xEnd; x++) { + const timeProgress = x / (plotWidth - 1); + const exactTimePos = timeProgress * (numTimeFrames - 1); + const timeIdx = Math.floor(exactTimePos); + const timeIdx2 = Math.min(timeIdx + 1, numTimeFrames - 1); + const timeFrac = exactTimePos - timeIdx; + const frame1 = spectrogramData[timeIdx]?.magnitudes ?? spectrogramData[0].magnitudes; + const frame2 = spectrogramData[timeIdx2]?.magnitudes ?? frame1; + for (let y = 0; y < MAX_RENDER_HEIGHT; y++) { + let freqProgress = (MAX_RENDER_HEIGHT - 1 - y) / (MAX_RENDER_HEIGHT - 1); + if (freqScale === "log2") { + const minFreq = 20; + const maxFreq = sampleRate / 2; + const octaves = Math.log2(maxFreq / minFreq); + const octave = freqProgress * octaves; + const freq = minFreq * Math.pow(2, octave); + freqProgress = freq / maxFreq; + } + const exactFreqPos = freqProgress * (numFreqBins - 1); + const freqIdx = Math.floor(exactFreqPos); + const freqIdx2 = Math.min(freqIdx + 1, numFreqBins - 1); + const freqFrac = exactFreqPos - freqIdx; + let magnitude: number; + if (timeFrac === 0 && freqFrac === 0) { + magnitude = frame1[freqIdx] ?? 0; + } + else { + const mag11 = frame1[freqIdx] ?? 0; + const mag12 = frame1[freqIdx2] ?? 0; + const mag21 = frame2[freqIdx] ?? 0; + const mag22 = frame2[freqIdx2] ?? 0; + const magT1 = mag11 * (1 - freqFrac) + mag12 * freqFrac; + const magT2 = mag21 * (1 - freqFrac) + mag22 * freqFrac; + magnitude = magT1 * (1 - timeFrac) + magT2 * timeFrac; + } + const normalizedMag = clamp01((magnitude - minMag) / safeMagRange); + const [r, g, b] = getColorValues(normalizedMag, colorScheme); + const pixelIdx = (y * plotWidth + x) * 4; + highResData[pixelIdx] = r; + highResData[pixelIdx + 1] = g; + highResData[pixelIdx + 2] = b; + highResData[pixelIdx + 3] = 255; + } + } + if (xStart + CHUNK_SIZE < plotWidth) { + await new Promise((resolve) => setTimeout(resolve, 1)); + } + } + if (shouldCancel()) { + return; + } + const finalImageData = ctx.createImageData(plotWidth, plotHeight); + const finalData = finalImageData.data; + for (let y = 0; y < plotHeight; y++) { + for (let x = 0; x < plotWidth; x++) { + const highResY = Math.round((y / plotHeight) * MAX_RENDER_HEIGHT); + const highResIdx = (highResY * plotWidth + x) * 4; + const finalIdx = (y * plotWidth + x) * 4; + if (highResIdx < highResData.length) { + finalData[finalIdx] = highResData[highResIdx]; + finalData[finalIdx + 1] = highResData[highResIdx + 1]; + finalData[finalIdx + 2] = highResData[highResIdx + 2]; + finalData[finalIdx + 3] = highResData[highResIdx + 3]; + } + } + } + ctx.putImageData(finalImageData, MARGIN.left, MARGIN.top); + addAxisLabels(ctx, plotWidth, plotHeight, sampleRate, duration, freqScale, fileName); + drawColorBar(ctx, plotHeight, colorScheme); +} +const COLOR_SCHEMES: { + value: ColorScheme; + label: string; + gradient: string; +}[] = [ + { value: "spek", label: "Spek", gradient: "linear-gradient(to right, #0f0040, #1e0080, #4000ff, #8000ff, #ff0080, #ff4000, #ff8000, #ffff00)" }, + { value: "viridis", label: "Viridis", gradient: "linear-gradient(to right, #440154, #31688e, #35b779, #fde725)" }, + { value: "hot", label: "Hot", gradient: "linear-gradient(to right, #000000, #ff0000, #ffff00, #ffffff)" }, + { value: "cool", label: "Cool", gradient: "linear-gradient(to right, #000080, #0000ff, #00ffff, #ffffff)" }, + { value: "grayscale", label: "Grayscale", gradient: "linear-gradient(to right, #000000, #ffffff)" }, +]; +export const SpectrumVisualization = forwardRef(({ sampleRate, duration, spectrumData, fileName, onReAnalyze, isAnalyzingSpectrum, spectrumProgress, }, ref) => { const canvasRef = useRef(null); + const preferencesRef = useRef(loadAudioAnalysisPreferences()); + useImperativeHandle(ref, () => ({ + getCanvasDataURL: () => { + if (!canvasRef.current) + return null; + return canvasRef.current.toDataURL("image/png"); + }, + })); + const [freqScale, setFreqScale] = useState(preferencesRef.current.freqScale); + const [colorScheme, setColorScheme] = useState(preferencesRef.current.colorScheme); + const [fftSize, setFftSize] = useState(() => String(preferencesRef.current.fftSize)); + const [windowFunction, setWindowFunction] = useState(preferencesRef.current.windowFunction); + useEffect(() => { + if (spectrumData?.freq_bins) { + setFftSize(String((spectrumData.freq_bins - 1) * 2)); + } + }, [spectrumData]); + useEffect(() => { + saveAudioAnalysisPreferences({ + colorScheme, + freqScale, + fftSize: Number(fftSize), + windowFunction, + }); + }, [colorScheme, freqScale, fftSize, windowFunction]); useEffect(() => { const canvas = canvasRef.current; if (!canvas) @@ -15,179 +465,107 @@ export function SpectrumVisualization({ sampleRate, bitsPerSample, duration, spe const ctx = canvas.getContext("2d"); if (!ctx) return; - const width = canvas.width; - const height = canvas.height; - const marginLeft = 70; - const marginRight = 70; - const marginTop = 30; - const marginBottom = 65; - const plotWidth = width - marginLeft - marginRight; - const plotHeight = height - marginTop - marginBottom; - ctx.fillStyle = "#000000"; - ctx.fillRect(0, 0, width, height); - const nyquistFreq = sampleRate / 2; + let canceled = false; + const shouldCancel = () => canceled; if (spectrumData) { - drawRealSpectrum(ctx, marginLeft, marginTop, plotWidth, plotHeight, spectrumData); - } - drawAxesAndLabels(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq, duration, sampleRate); - drawColorBar(ctx, marginLeft + plotWidth + 15, marginTop, 20, plotHeight); - }, [sampleRate, bitsPerSample, duration, spectrumData]); - const drawRealSpectrum = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, spectrum: SpectrumData) => { - const timeSlices = spectrum.time_slices; - if (timeSlices.length === 0) - return; - const freqBins = timeSlices[0].magnitudes.length; - const nyquistFreq = spectrum.max_freq; - let minDB = 0; - let maxDB = -200; - timeSlices.forEach((slice) => { - slice.magnitudes.forEach((db) => { - if (db > maxDB) - maxDB = db; - if (db < minDB && db > -200) - minDB = db; - }); - }); - minDB = Math.max(minDB, maxDB - 90); - const dbRange = maxDB - minDB; - const sliceWidth = Math.ceil(width / timeSlices.length); - for (let t = 0; t < timeSlices.length; t++) { - const slice = timeSlices[t]; - const xPos = x + (t / timeSlices.length) * width; - for (let f = 0; f < freqBins && f < slice.magnitudes.length; f++) { - const db = slice.magnitudes[f]; - const freq = (f / freqBins) * nyquistFreq; - const freqRatio = freq / nyquistFreq; - const yPos = y + height - (freqRatio * height); - const nextFreq = ((f + 1) / freqBins) * nyquistFreq; - const nextFreqRatio = nextFreq / nyquistFreq; - const nextYPos = y + height - (nextFreqRatio * height); - const binHeight = Math.max(1, Math.abs(yPos - nextYPos) + 1); - const intensity = Math.max(0, Math.min(1, (db - minDB) / dbRange)); - const color = getSpekColor(intensity); - ctx.fillStyle = color; - ctx.fillRect(xPos, nextYPos, sliceWidth, binHeight); - } - } - }; - const getSpekColor = (intensity: number): string => { - if (intensity < 0.08) { - const t = intensity / 0.08; - return `rgb(0, 0, ${Math.floor(t * 80)})`; - } - else if (intensity < 0.18) { - const t = (intensity - 0.08) / 0.10; - return `rgb(${Math.floor(t * 50)}, ${Math.floor(t * 30)}, ${Math.floor(80 + t * 175)})`; - } - else if (intensity < 0.28) { - const t = (intensity - 0.18) / 0.10; - return `rgb(${Math.floor(50 + t * 150)}, ${Math.floor(30 - t * 30)}, ${Math.floor(255 - t * 55)})`; - } - else if (intensity < 0.40) { - const t = (intensity - 0.28) / 0.12; - return `rgb(${Math.floor(200 + t * 55)}, 0, ${Math.floor(200 - t * 200)})`; - } - else if (intensity < 0.52) { - const t = (intensity - 0.40) / 0.12; - return `rgb(255, ${Math.floor(t * 100)}, 0)`; - } - else if (intensity < 0.65) { - const t = (intensity - 0.52) / 0.13; - return `rgb(255, ${Math.floor(100 + t * 80)}, 0)`; - } - else if (intensity < 0.78) { - const t = (intensity - 0.65) / 0.13; - return `rgb(255, ${Math.floor(180 + t * 55)}, ${Math.floor(t * 30)})`; - } - else if (intensity < 0.90) { - const t = (intensity - 0.78) / 0.12; - return `rgb(255, ${Math.floor(235 + t * 20)}, ${Math.floor(30 + t * 100)})`; + void renderSpectrogram(ctx, spectrumData, sampleRate, duration, freqScale, colorScheme, fileName, shouldCancel); } else { - const t = (intensity - 0.90) / 0.10; - return `rgb(255, 255, ${Math.floor(130 + t * 125)})`; + ctx.fillStyle = "#000000"; + ctx.fillRect(0, 0, CANVAS_W, CANVAS_H); + ctx.fillStyle = "#444444"; + ctx.font = "16px Arial"; + ctx.textAlign = "center"; + ctx.fillText("No spectrum data", CANVAS_W / 2, CANVAS_H / 2); + } + return () => { + canceled = true; + }; + }, [spectrumData, sampleRate, duration, freqScale, colorScheme, fileName]); + const handleReAnalyze = (newFftSize: string, newWindowFunc: string) => { + setFftSize(newFftSize); + setWindowFunction(newWindowFunc as WindowFunction); + if (onReAnalyze) { + onReAnalyze(parseInt(newFftSize, 10), newWindowFunc); } }; - const drawAxesAndLabels = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, nyquistFreq: number, duration: number, sampleRate: number) => { - ctx.fillStyle = "#CCCCCC"; - ctx.font = "12px Arial"; - ctx.textAlign = "right"; - ctx.textBaseline = "middle"; - const freqLabels = generateFreqLabels(nyquistFreq); - freqLabels.forEach(freq => { - if (freq <= nyquistFreq) { - const freqRatio = freq / nyquistFreq; - const yPos = y + height - (freqRatio * height); - const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`; - ctx.fillText(label, x - 8, yPos); - } - }); - ctx.fillText("0", x - 8, y + height); - ctx.textAlign = "center"; - ctx.textBaseline = "top"; - const timeStep = getTimeStep(duration); - for (let t = 0; t <= duration; t += timeStep) { - const xPos = x + (t / duration) * width; - ctx.fillText(`${Math.round(t)}s`, xPos, y + height + 8); - } - ctx.fillStyle = "#FFFFFF"; - ctx.font = "13px Arial"; - ctx.save(); - ctx.translate(12, y + height / 2); - ctx.rotate(-Math.PI / 2); - ctx.textAlign = "center"; - ctx.fillText("Frequency (Hz)", 0, 0); - ctx.restore(); - ctx.textAlign = "center"; - ctx.fillText("Time (seconds)", x + width / 2, y + height + 35); - ctx.textAlign = "right"; - ctx.fillStyle = "#CCCCCC"; - ctx.font = "12px Arial"; - ctx.fillText(`Sample Rate: ${sampleRate} Hz`, x + width - 5, y - 3); - }; - const generateFreqLabels = (nyquistFreq: number): number[] => { - if (nyquistFreq <= 24000) { - return [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000, 22000]; - } - else if (nyquistFreq <= 48000) { - return [5000, 10000, 15000, 20000, 25000, 30000, 35000, 40000, 45000]; - } - else if (nyquistFreq <= 96000) { - return [10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000]; - } - else { - return [20000, 40000, 60000, 80000, 100000, 120000, 140000, 160000, 180000]; - } - }; - const getTimeStep = (duration: number): number => { - if (duration <= 60) - return 15; - if (duration <= 120) - return 30; - if (duration <= 300) - return 30; - if (duration <= 600) - return 60; - return 60; - }; - const drawColorBar = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number) => { - for (let i = 0; i < height; i++) { - const intensity = 1 - (i / height); - const color = getSpekColor(intensity); - ctx.fillStyle = color; - ctx.fillRect(x, y + i, width, 1); - } - ctx.strokeStyle = "#666666"; - ctx.lineWidth = 1; - ctx.strokeRect(x, y, width, height); - ctx.fillStyle = "#FFFFFF"; - ctx.font = "11px Arial"; - ctx.textAlign = "left"; - ctx.textBaseline = "middle"; - ctx.fillText("High", x + width + 5, y + 10); - ctx.fillText("Low", x + width + 5, y + height - 10); - }; - return (
- -
); -} + const spectrumPercent = Math.round(Math.max(0, Math.min(100, spectrumProgress?.percent ?? 0))); + return (
+
+
+ + +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ {isAnalyzingSpectrum && (
+
+
+ Processing... + {spectrumPercent}% +
+ +
+
)} + +
+
); +}); diff --git a/frontend/src/components/ui/audio-lines.tsx b/frontend/src/components/ui/audio-lines.tsx new file mode 100644 index 0000000..0040347 --- /dev/null +++ b/frontend/src/components/ui/audio-lines.tsx @@ -0,0 +1,87 @@ +"use client"; +import { motion, useAnimation } from "motion/react"; +import type { HTMLAttributes } from "react"; +import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; +import { cn } from "@/lib/utils"; +export interface AudioLinesIconHandle { + startAnimation: () => void; + stopAnimation: () => void; +} +interface AudioLinesIconProps extends HTMLAttributes { + size?: number; +} +const AudioLinesIcon = forwardRef(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => { + const controls = useAnimation(); + const isControlledRef = useRef(false); + useImperativeHandle(ref, () => { + isControlledRef.current = true; + return { + startAnimation: () => controls.start("animate"), + stopAnimation: () => controls.start("normal"), + }; + }); + const handleMouseEnter = useCallback((e: React.MouseEvent) => { + if (isControlledRef.current) { + onMouseEnter?.(e); + } + else { + controls.start("animate"); + } + }, [controls, onMouseEnter]); + const handleMouseLeave = useCallback((e: React.MouseEvent) => { + if (isControlledRef.current) { + onMouseLeave?.(e); + } + else { + controls.start("normal"); + } + }, [controls, onMouseLeave]); + return (
+ + + + + + + + +
); +}); +AudioLinesIcon.displayName = "AudioLinesIcon"; +export { AudioLinesIcon }; diff --git a/frontend/src/hooks/useAudioAnalysis.ts b/frontend/src/hooks/useAudioAnalysis.ts index 351f134..fd4838c 100644 --- a/frontend/src/hooks/useAudioAnalysis.ts +++ b/frontend/src/hooks/useAudioAnalysis.ts @@ -1,147 +1,338 @@ -import { useState, useCallback, useEffect } from "react"; -import { AnalyzeTrack } from "../../wailsjs/go/main/App"; +import { useState, useCallback, useRef, useEffect, type MutableRefObject } from "react"; import type { AnalysisResult } from "@/types/api"; import { logger } from "@/lib/logger"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; -import { setSpectrumCache, getSpectrumCache, clearSpectrumCache } from "@/lib/spectrum-cache"; -const STORAGE_KEY = "spotiflac_audio_analysis_state"; +import { analyzeAudioArrayBuffer, analyzeAudioFile, analyzeSpectrumFromSamples, type AnalysisProgress, } from "@/lib/flac-analysis"; +import { loadAudioAnalysisPreferences } from "@/lib/audio-analysis-preferences"; +type WindowFunction = "hann" | "hamming" | "blackman" | "rectangular"; +function toWindowFunction(value: string): WindowFunction { + switch (value) { + case "hamming": + case "blackman": + case "rectangular": + return value; + case "hann": + default: + return "hann"; + } +} +function fileNameFromPath(filePath: string): string { + const parts = filePath.split(/[/\\]/); + return parts[parts.length - 1] || filePath; +} +function nextUiTick(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} +async function base64ToArrayBuffer(base64: string, shouldCancel?: () => boolean): Promise { + const clean = base64.includes(",") ? base64.split(",")[1] : base64; + const padding = clean.endsWith("==") ? 2 : clean.endsWith("=") ? 1 : 0; + const outputLength = Math.floor((clean.length * 3) / 4) - padding; + const bytes = new Uint8Array(outputLength); + const chunkSize = 4 * 16384; + let writeOffset = 0; + for (let offset = 0; offset < clean.length; offset += chunkSize) { + if (shouldCancel?.()) { + throw new Error("Analysis cancelled"); + } + const chunk = clean.slice(offset, Math.min(clean.length, offset + chunkSize)); + const binary = atob(chunk); + for (let i = 0; i < binary.length; i++) { + bytes[writeOffset++] = binary.charCodeAt(i); + } + if ((offset / chunkSize) % 4 === 0) { + await nextUiTick(); + } + } + return bytes.buffer; +} +let sessionResult: AnalysisResult | null = null; +let sessionSelectedFilePath = ""; +let sessionError: string | null = null; +let sessionSamples: Float32Array | null = null; +interface ProgressState { + percent: number; + message: string; +} +const DEFAULT_PROGRESS_STATE: ProgressState = { + percent: 0, + message: "Preparing analysis...", +}; +interface CancelToken { + cancelled: boolean; +} +function cancelToken(tokenRef: MutableRefObject): void { + if (tokenRef.current) { + tokenRef.current.cancelled = true; + tokenRef.current = null; + } +} +function createToken(tokenRef: MutableRefObject): CancelToken { + cancelToken(tokenRef); + const token: CancelToken = { cancelled: false }; + tokenRef.current = token; + return token; +} +function isCancelledError(error: unknown): boolean { + return error instanceof Error && error.message === "Analysis cancelled"; +} +function toProgressState(progress: AnalysisProgress): ProgressState { + return { + percent: Math.round(Math.max(0, Math.min(100, progress.percent))), + message: progress.message, + }; +} export function useAudioAnalysis() { const [analyzing, setAnalyzing] = useState(false); - const [result, setResult] = useState(() => { - try { - const saved = sessionStorage.getItem(STORAGE_KEY); - if (saved) { - const parsed = JSON.parse(saved); - if (parsed.filePath && parsed.result) { - return { - ...parsed.result, - spectrum: undefined, - }; - } - } - } - catch (err) { - console.error("Failed to load saved analysis state:", err); - } - return null; - }); - const [selectedFilePath, setSelectedFilePath] = useState(() => { - try { - const saved = sessionStorage.getItem(STORAGE_KEY); - if (saved) { - const parsed = JSON.parse(saved); - return parsed.filePath || ""; - } - } - catch (err) { - } - return ""; - }); - const [error, setError] = useState(null); - const [spectrumLoading, setSpectrumLoading] = useState(() => { - try { - const saved = sessionStorage.getItem(STORAGE_KEY); - if (saved) { - const parsed = JSON.parse(saved); - if (parsed.filePath && parsed.result) { - return true; - } - } - } - catch (err) { - } - return false; - }); - const analyzeFile = useCallback(async (filePath: string) => { - if (!filePath) { - setError("No file path provided"); + const [analysisProgress, setAnalysisProgress] = useState(DEFAULT_PROGRESS_STATE); + const [result, setResult] = useState(() => sessionResult); + const [selectedFilePath, setSelectedFilePath] = useState(() => sessionSelectedFilePath); + const [error, setError] = useState(() => sessionError); + const [spectrumLoading, setSpectrumLoading] = useState(false); + const [spectrumProgress, setSpectrumProgress] = useState(DEFAULT_PROGRESS_STATE); + const samplesRef = useRef(sessionSamples); + const analysisTokenRef = useRef(null); + const spectrumTokenRef = useRef(null); + useEffect(() => { + return () => { + cancelToken(analysisTokenRef); + cancelToken(spectrumTokenRef); + }; + }, []); + const setResultWithSession = useCallback((next: AnalysisResult | null) => { + sessionResult = next; + setResult(next); + }, []); + const setSelectedFilePathWithSession = useCallback((next: string) => { + sessionSelectedFilePath = next; + setSelectedFilePath(next); + }, []); + const setErrorWithSession = useCallback((next: string | null) => { + sessionError = next; + setError(next); + }, []); + const analyzeFile = useCallback(async (file: File) => { + if (!file) { + setErrorWithSession("No file provided"); return null; } + const token = createToken(analysisTokenRef); + cancelToken(spectrumTokenRef); setAnalyzing(true); - setError(null); - setResult(null); - setSelectedFilePath(filePath); + setAnalysisProgress({ + percent: 1, + message: "Preparing file...", + }); + setErrorWithSession(null); + setResultWithSession(null); + setSelectedFilePathWithSession(file.name); try { - logger.info(`Analyzing audio file: ${filePath}`); - const startTime = Date.now(); - const response = await AnalyzeTrack(filePath); - const analysisResult: AnalysisResult = JSON.parse(response); - const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); + logger.info(`Analyzing audio file (frontend): ${file.name}`); + const start = Date.now(); + const prefs = loadAudioAnalysisPreferences(); + const payload = await analyzeAudioFile(file, { + fftSize: prefs.fftSize, + windowFunction: prefs.windowFunction, + }, (progress) => { + if (token.cancelled) + return; + setAnalysisProgress(toProgressState(progress)); + }, () => token.cancelled); + if (token.cancelled) { + return null; + } + samplesRef.current = payload.samples; + sessionSamples = payload.samples; + setResultWithSession(payload.result); + const elapsed = ((Date.now() - start) / 1000).toFixed(2); logger.success(`Audio analysis completed in ${elapsed}s`); - if (analysisResult.spectrum) { - setSpectrumCache(filePath, analysisResult.spectrum); - } - const { spectrum, ...detailResult } = analysisResult; - try { - sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ - filePath, - result: detailResult, - })); - } - catch (err) { - console.error("Failed to save analysis state:", err); - } - setResult(analysisResult); - setSpectrumLoading(false); - return analysisResult; + return payload.result; } catch (err) { + if (isCancelledError(err)) { + return null; + } const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file"; logger.error(`Analysis error: ${errorMessage}`); - setError(errorMessage); + setErrorWithSession(errorMessage); + setAnalysisProgress({ + percent: 0, + message: "Analysis failed", + }); toast.error("Audio Analysis Failed", { description: errorMessage, }); return null; } finally { - setAnalyzing(false); + if (analysisTokenRef.current === token) { + analysisTokenRef.current = null; + setAnalyzing(false); + } } - }, []); - const clearResult = useCallback(() => { - setResult(null); - setError(null); - setSelectedFilePath(""); + }, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]); + const analyzeFilePath = useCallback(async (filePath: string) => { + if (!filePath) { + setErrorWithSession("No file path provided"); + return null; + } + const token = createToken(analysisTokenRef); + cancelToken(spectrumTokenRef); + setAnalyzing(true); + setAnalysisProgress({ + percent: 1, + message: "Reading file from disk...", + }); + setErrorWithSession(null); + setResultWithSession(null); + setSelectedFilePathWithSession(filePath); try { - sessionStorage.removeItem(STORAGE_KEY); + 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; + if (!readFileAsBase64) { + throw new Error("ReadFileAsBase64 backend method is unavailable"); + } + let base64Data = await readFileAsBase64(filePath); + if (token.cancelled) { + return null; + } + setAnalysisProgress({ + percent: 10, + message: "File loaded", + }); + const arrayBuffer = await base64ToArrayBuffer(base64Data, () => token.cancelled); + base64Data = ""; + if (token.cancelled) { + return null; + } + setAnalysisProgress({ + percent: 15, + message: "Preparing audio buffer...", + }); + const fileName = fileNameFromPath(filePath); + const payload = await analyzeAudioArrayBuffer({ + fileName, + fileSize: arrayBuffer.byteLength, + arrayBuffer, + }, { + fftSize: prefs.fftSize, + windowFunction: prefs.windowFunction, + }, (progress) => { + 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; + } + samplesRef.current = payload.samples; + sessionSamples = payload.samples; + setResultWithSession(payload.result); + const elapsed = ((Date.now() - start) / 1000).toFixed(2); + logger.success(`Audio analysis completed in ${elapsed}s`); + return payload.result; } catch (err) { - } - clearSpectrumCache(); - }, []); - useEffect(() => { - if (!result || !selectedFilePath || result.spectrum || !spectrumLoading) { - return; - } - let rafId: number; - const loadSpectrum = () => { - rafId = requestAnimationFrame(() => { - const cachedSpectrum = getSpectrumCache(selectedFilePath); - if (cachedSpectrum) { - setResult(prev => prev ? { ...prev, spectrum: cachedSpectrum } : null); - setSpectrumLoading(false); - } - else { - setSpectrumLoading(false); - } - }); - }; - requestAnimationFrame(() => { - requestAnimationFrame(loadSpectrum); - }); - return () => { - if (rafId) { - cancelAnimationFrame(rafId); + if (isCancelledError(err)) { + return null; } - }; - }, [result, selectedFilePath, spectrumLoading]); + const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file"; + logger.error(`Analysis error: ${errorMessage}`); + setErrorWithSession(errorMessage); + setAnalysisProgress({ + percent: 0, + message: "Analysis failed", + }); + toast.error("Audio Analysis Failed", { + description: errorMessage, + }); + return null; + } + finally { + if (analysisTokenRef.current === token) { + analysisTokenRef.current = null; + setAnalyzing(false); + } + } + }, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]); + const reAnalyzeSpectrum = useCallback(async (fftSize: number, windowFunction: string) => { + if (!result || !samplesRef.current) + return; + const token = createToken(spectrumTokenRef); + setSpectrumLoading(true); + setSpectrumProgress({ + percent: 0, + message: "Preparing FFT...", + }); + try { + await new Promise((resolve) => setTimeout(resolve, 0)); + const spectrum = await analyzeSpectrumFromSamples(samplesRef.current, result.sample_rate, { + fftSize, + windowFunction: toWindowFunction(windowFunction), + }, (progress) => { + if (token.cancelled) + return; + setSpectrumProgress(toProgressState(progress)); + }, () => token.cancelled); + if (token.cancelled) { + return; + } + setResult((prev) => { + const next = prev ? { ...prev, spectrum } : prev; + sessionResult = next; + return next; + }); + } + catch (err) { + if (isCancelledError(err)) { + return; + } + const errorMessage = err instanceof Error ? err.message : "Failed to re-analyze spectrum"; + logger.error(`Spectrum re-analysis error: ${errorMessage}`); + setSpectrumProgress({ + percent: 0, + message: "Spectrum analysis failed", + }); + toast.error("Spectrum Analysis Failed", { + description: errorMessage, + }); + } + finally { + if (spectrumTokenRef.current === token) { + spectrumTokenRef.current = null; + setSpectrumLoading(false); + } + } + }, [result]); + const clearResult = useCallback(() => { + cancelToken(analysisTokenRef); + cancelToken(spectrumTokenRef); + setAnalyzing(false); + setResultWithSession(null); + setErrorWithSession(null); + setSelectedFilePathWithSession(""); + setSpectrumLoading(false); + setAnalysisProgress(DEFAULT_PROGRESS_STATE); + setSpectrumProgress(DEFAULT_PROGRESS_STATE); + samplesRef.current = null; + sessionSamples = null; + }, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]); return { analyzing, + analysisProgress, result, error, selectedFilePath, spectrumLoading, + spectrumProgress, analyzeFile, + analyzeFilePath, + reAnalyzeSpectrum, clearResult, }; } diff --git a/frontend/src/hooks/useMetadata.ts b/frontend/src/hooks/useMetadata.ts index 8477b54..80700e7 100644 --- a/frontend/src/hooks/useMetadata.ts +++ b/frontend/src/hooks/useMetadata.ts @@ -1,13 +1,17 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { getSettings } from "@/lib/settings"; import { fetchSpotifyMetadata } from "@/lib/api"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { logger } from "@/lib/logger"; import { AddFetchHistory } from "../../wailsjs/go/main/App"; +import { EventsOff, EventsOn } from "../../wailsjs/runtime/runtime"; import type { SpotifyMetadataResponse } from "@/types/api"; export function useMetadata() { const [loading, setLoading] = useState(false); const [metadata, setMetadata] = useState(null); + const loadingToastId = useRef(null); + const fetchedCount = useRef(0); + const currentName = useRef(""); const [showApiModal, setShowApiModal] = useState(false); const [showAlbumDialog, setShowAlbumDialog] = useState(false); const [selectedAlbum, setSelectedAlbum] = useState<{ @@ -16,6 +20,73 @@ export function useMetadata() { external_urls: string; } | null>(null); const [pendingArtistName, setPendingArtistName] = useState(null); + useEffect(() => { + if (loading) { + fetchedCount.current = 0; + currentName.current = ""; + loadingToastId.current = toast.silentInfo("fetching metadata...", { + duration: Infinity, + description: "please wait while we retrieve the information" + }); + return; + } + if (loadingToastId.current) { + toast.dismiss(loadingToastId.current); + loadingToastId.current = null; + } + }, [loading]); + useEffect(() => { + const handler = (data: any) => { + if (!data) { + return; + } + if (Array.isArray(data)) { + fetchedCount.current += data.length; + if (loadingToastId.current && currentName.current) { + toast.silentInfo(`fetching tracks for ${currentName.current.toLowerCase()}...`, { + id: loadingToastId.current, + description: `${fetchedCount.current.toLocaleString()} tracks fetched` + }); + } + } + else { + const baseInfo = data; + const name = "artist_info" in baseInfo ? baseInfo.artist_info.name : + "album_info" in baseInfo ? baseInfo.album_info.name : + "playlist_info" in baseInfo ? (baseInfo.playlist_info.name || baseInfo.playlist_info.owner.name) : ""; + if (name) { + currentName.current = name; + if (loadingToastId.current) { + toast.silentInfo(`fetching tracks for ${name.toLowerCase()}...`, { + id: loadingToastId.current, + description: `${fetchedCount.current.toLocaleString()} tracks fetched` + }); + } + } + } + setMetadata(prev => { + if (Array.isArray(data)) { + if (!prev || !("track_list" in prev)) { + return prev; + } + return { + ...prev, + track_list: [...prev.track_list, ...data] + }; + } + if (prev && "track_list" in prev && prev.track_list.length > 0) { + return prev; + } + const baseInfo = data; + if (!("track_list" in baseInfo)) { + baseInfo.track_list = []; + } + return baseInfo; + }); + }; + EventsOn("metadata-stream", handler); + return () => EventsOff("metadata-stream"); + }, []); const getUrlType = (url: string): string => { if (url.includes("/track/")) return "track"; diff --git a/frontend/src/hooks/usePreview.ts b/frontend/src/hooks/usePreview.ts index 72c3abe..479346d 100644 --- a/frontend/src/hooks/usePreview.ts +++ b/frontend/src/hooks/usePreview.ts @@ -1,5 +1,6 @@ import { useState, useEffect } from "react"; import { GetPreviewURL } from "@/../wailsjs/go/main/App"; +import { SPOTIFY_PREVIEW_VOLUME } from "@/lib/preview"; import { toast } from "sonner"; export function usePreview() { const [loadingPreview, setLoadingPreview] = useState(null); @@ -38,6 +39,7 @@ export function usePreview() { return; } const audio = new Audio(previewURL); + audio.volume = SPOTIFY_PREVIEW_VOLUME; audio.addEventListener("loadeddata", () => { setLoadingPreview(null); setPlayingTrack(trackId); diff --git a/frontend/src/lib/audio-analysis-preferences.ts b/frontend/src/lib/audio-analysis-preferences.ts new file mode 100644 index 0000000..66bb3d1 --- /dev/null +++ b/frontend/src/lib/audio-analysis-preferences.ts @@ -0,0 +1,63 @@ +export type AnalyzerColorScheme = "spek" | "viridis" | "hot" | "cool" | "grayscale"; +export type AnalyzerFreqScale = "linear" | "log2"; +export type AnalyzerWindowFunction = "hann" | "hamming" | "blackman" | "rectangular"; +export interface AudioAnalysisPreferences { + colorScheme: AnalyzerColorScheme; + freqScale: AnalyzerFreqScale; + fftSize: number; + windowFunction: AnalyzerWindowFunction; +} +const STORAGE_KEY = "spotiflac_audio_analysis_preferences"; +const DEFAULT_PREFERENCES: AudioAnalysisPreferences = { + colorScheme: "spek", + freqScale: "linear", + fftSize: 4096, + windowFunction: "hann", +}; +const FFT_SIZE_SET = new Set([512, 1024, 2048, 4096]); +function toColorScheme(value: unknown): AnalyzerColorScheme { + return value === "viridis" || value === "hot" || value === "cool" || value === "grayscale" + ? value + : "spek"; +} +function toFreqScale(value: unknown): AnalyzerFreqScale { + return value === "log2" ? "log2" : "linear"; +} +function toFFTSize(value: unknown): number { + const num = typeof value === "number" ? value : Number(value); + return FFT_SIZE_SET.has(num) ? num : 4096; +} +function toWindowFunction(value: unknown): AnalyzerWindowFunction { + return value === "hamming" || value === "blackman" || value === "rectangular" + ? value + : "hann"; +} +export function loadAudioAnalysisPreferences(): AudioAnalysisPreferences { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) + return DEFAULT_PREFERENCES; + const parsed = JSON.parse(raw) as Partial; + return { + colorScheme: toColorScheme(parsed.colorScheme), + freqScale: toFreqScale(parsed.freqScale), + fftSize: toFFTSize(parsed.fftSize), + windowFunction: toWindowFunction(parsed.windowFunction), + }; + } + catch { + return DEFAULT_PREFERENCES; + } +} +export function saveAudioAnalysisPreferences(preferences: AudioAnalysisPreferences): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + colorScheme: toColorScheme(preferences.colorScheme), + freqScale: toFreqScale(preferences.freqScale), + fftSize: toFFTSize(preferences.fftSize), + windowFunction: toWindowFunction(preferences.windowFunction), + })); + } + catch { + } +} diff --git a/frontend/src/lib/flac-analysis.ts b/frontend/src/lib/flac-analysis.ts new file mode 100644 index 0000000..d3bc39c --- /dev/null +++ b/frontend/src/lib/flac-analysis.ts @@ -0,0 +1,727 @@ +import type { AnalysisResult, SpectrumData, TimeSlice } from "@/types/api"; +export interface SpectrumParams { + fftSize: number; + windowFunction: "hann" | "hamming" | "blackman" | "rectangular"; +} +const DEFAULT_PARAMS: SpectrumParams = { + fftSize: 4096, + windowFunction: "hann", +}; +const MAX_SPECTRUM_FRAMES = 2200; +const METRICS_CHUNK_SIZE = 262144; +const AAC_SAMPLE_RATES = [ + 96000, 88200, 64000, 48000, 44100, 32000, 24000, + 22050, 16000, 12000, 11025, 8000, 7350, +] as const; +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 { + fileType: SupportedAudioFileType; + sampleRate: number; + channels: number; + bitsPerSample: number; + totalSamples: number; + duration: number; + codecMode?: string; + bitrateKbps?: number; + totalFrames?: number; + codecVersion?: string; +} +interface Mp4BoxInfo { + offset: number; + size: number; + headerSize: number; + type: string; +} +export interface FrontendAnalysisPayload { + result: AnalysisResult; + samples: Float32Array; +} +export interface AudioArrayBufferInput { + fileName: string; + fileSize: number; + arrayBuffer: ArrayBuffer; +} +export type AnalysisPhase = "read" | "parse" | "decode" | "metrics" | "spectrum" | "finalize"; +export interface AnalysisProgress { + phase: AnalysisPhase; + percent: number; + message: string; +} +export type AnalysisProgressCallback = (progress: AnalysisProgress) => void; +export type AnalysisCancelCheck = () => boolean; +function reportProgress(callback: AnalysisProgressCallback | undefined, phase: AnalysisPhase, percent: number, message: string): void { + if (!callback) + return; + callback({ + phase, + percent: Math.max(0, Math.min(100, percent)), + message, + }); +} +function throwIfCancelled(cancelCheck?: AnalysisCancelCheck): void { + if (cancelCheck?.()) { + throw new Error("Analysis cancelled"); + } +} +function nowMs(): number { + return typeof performance !== "undefined" ? performance.now() : Date.now(); +} +function nextTick(): Promise { + if (typeof requestAnimationFrame === "function") { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); + } + return new Promise((resolve) => setTimeout(resolve, 0)); +} +function readFourCC(view: DataView, offset: number): string { + return String.fromCharCode(view.getUint8(offset), view.getUint8(offset + 1), view.getUint8(offset + 2), view.getUint8(offset + 3)); +} +function fileExtension(fileName: string): string { + const normalized = fileName.toLowerCase(); + const dotIndex = normalized.lastIndexOf("."); + return dotIndex >= 0 ? normalized.slice(dotIndex) : ""; +} +function detectAudioFileType(buffer: ArrayBuffer, fileName = ""): SupportedAudioFileType { + const view = new DataView(buffer); + if (view.byteLength >= 4 && view.getUint32(0, false) === 0x664c6143) { + return "FLAC"; + } + if (view.byteLength >= 3 && + view.getUint8(0) === 0x49 && + view.getUint8(1) === 0x44 && + view.getUint8(2) === 0x33) { + return "MP3"; + } + if (view.byteLength >= 8 && readFourCC(view, 4) === "ftyp") { + return "M4A"; + } + if (view.byteLength >= 2 && view.getUint8(0) === 0xff && (view.getUint8(1) & 0xf6) === 0xf0) { + return "AAC"; + } + for (let offset = 0; offset < Math.min(4096, view.byteLength - 4); offset++) { + const header = view.getUint32(offset, false); + if ((header >>> 21) === 0x7ff) { + const version = (header >>> 19) & 0x03; + const layer = (header >>> 17) & 0x03; + const sampleRateIndex = (header >>> 10) & 0x03; + if (version !== 1 && layer !== 0 && sampleRateIndex !== 3) { + return "MP3"; + } + } + } + switch (fileExtension(fileName)) { + case ".flac": return "FLAC"; + case ".mp3": return "MP3"; + case ".m4a": + case ".mp4": return "M4A"; + case ".aac": return "AAC"; + default: throw new Error(`Unsupported audio format: ${fileName || "unknown"}`); + } +} +function parseFlacMetadata(buffer: ArrayBuffer): ParsedAudioMetadata { + const data = new Uint8Array(buffer); + if (data.length < 4 || data[0] !== 0x66 || data[1] !== 0x4c || data[2] !== 0x61 || data[3] !== 0x43) { + throw new Error("Invalid FLAC file"); + } + let offset = 4; + while (offset + 4 <= data.length) { + const blockHeader = data[offset]; + const blockType = blockHeader & 0x7f; + const blockLength = (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3]; + offset += 4; + if (offset + blockLength > data.length) + break; + if (blockType === 0 && blockLength >= 18) { + const streamInfo = data.subarray(offset, offset + blockLength); + const sampleRate = (streamInfo[10] << 12) | (streamInfo[11] << 4) | (streamInfo[12] >> 4); + const channels = ((streamInfo[12] >> 1) & 0x07) + 1; + const bitsPerSample = (((streamInfo[12] & 0x01) << 4) | (streamInfo[13] >> 4)) + 1; + const totalSamplesBig = (BigInt(streamInfo[13] & 0x0f) << 32n) | + (BigInt(streamInfo[14]) << 24n) | + (BigInt(streamInfo[15]) << 16n) | + (BigInt(streamInfo[16]) << 8n) | + BigInt(streamInfo[17]); + const totalSamples = Number(totalSamplesBig); + const duration = sampleRate > 0 && totalSamples > 0 ? totalSamples / sampleRate : 0; + return { + fileType: "FLAC", + sampleRate, + channels, + bitsPerSample, + totalSamples, + duration, + }; + } + offset += blockLength; + } + throw new Error("FLAC STREAMINFO metadata not found"); +} +function skipId3v2Tag(view: DataView): number { + if (view.byteLength < 10 || + view.getUint8(0) !== 0x49 || + view.getUint8(1) !== 0x44 || + view.getUint8(2) !== 0x33) { + return 0; + } + const size = ((view.getUint8(6) & 0x7f) << 21) | + ((view.getUint8(7) & 0x7f) << 14) | + ((view.getUint8(8) & 0x7f) << 7) | + (view.getUint8(9) & 0x7f); + let offset = 10 + size; + if ((view.getUint8(5) & 0x10) !== 0) { + offset += 10; + } + return offset < view.byteLength ? offset : 0; +} +function getMp3Bitrate(version: number, layer: number, bitrateIndex: number): number { + const tables: Record> = { + 1: { + 1: [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, 0], + 2: [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 0], + 3: [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0], + }, + 2: { + 1: [0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 0], + 2: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0], + 3: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0], + }, + }; + const normalizedVersion = version === 2.5 ? 2 : version; + return tables[normalizedVersion]?.[layer]?.[bitrateIndex] ?? 0; +} +function getMp3SamplesPerFrame(version: number, layer: number): number { + if (layer === 1) + return 384; + if (version === 1) + return 1152; + return 576; +} +interface Mp3FrameInfo { + version: number; + versionName: string; + layer: number; + sampleRate: number; + bitrate: number; + channels: number; + frameSize: number; + samplesPerFrame: number; +} +function parseMp3FrameHeader(header: number): Mp3FrameInfo | null { + if (((header >>> 21) & 0x7ff) !== 0x7ff) + return null; + const versionBits = (header >>> 19) & 0x03; + const layerBits = (header >>> 17) & 0x03; + const bitrateIndex = (header >>> 12) & 0x0f; + const sampleRateIndex = (header >>> 10) & 0x03; + const padding = (header >>> 9) & 0x01; + const channelMode = (header >>> 6) & 0x03; + const versions = [2.5, null, 2, 1] as const; + const layers = [null, 3, 2, 1] as const; + const version = versions[versionBits]; + const layer = layers[layerBits]; + if (version === null || layer === null || sampleRateIndex === 3) + return null; + const sampleRateTables: Record<1 | 2 | 25, [ + number, + number, + number + ]> = { + 1: [44100, 48000, 32000], + 2: [22050, 24000, 16000], + 25: [11025, 12000, 8000], + }; + const sampleRateKey = version === 2.5 ? 25 : (version as 1 | 2); + const sampleRate = sampleRateTables[sampleRateKey][sampleRateIndex]; + const bitrate = getMp3Bitrate(version, layer, bitrateIndex); + const samplesPerFrame = getMp3SamplesPerFrame(version, layer); + if (!sampleRate || !bitrate || !samplesPerFrame) + return null; + return { + version, + versionName: `MPEG-${version === 1 ? "1" : version === 2 ? "2" : "2.5"}`, + layer, + sampleRate, + bitrate, + channels: channelMode === 3 ? 1 : 2, + frameSize: Math.floor((samplesPerFrame / 8 * bitrate * 1000) / sampleRate) + padding, + samplesPerFrame, + }; +} +function getMp3SideInfoSize(frameInfo: Mp3FrameInfo): number { + if (frameInfo.version === 1) { + return frameInfo.channels === 1 ? 17 : 32; + } + return frameInfo.channels === 1 ? 9 : 17; +} +function parseMp3XingHeader(view: DataView, offset: number, frameInfo: Mp3FrameInfo) { + if (offset + 16 > view.byteLength) + return null; + const flags = view.getUint32(offset + 4, false); + let pos = offset + 8; + let totalFrames = 0; + let totalBytes = 0; + if ((flags & 0x01) !== 0 && pos + 4 <= view.byteLength) { + totalFrames = view.getUint32(pos, false); + pos += 4; + } + if ((flags & 0x02) !== 0 && pos + 4 <= view.byteLength) { + totalBytes = view.getUint32(pos, false); + } + const duration = totalFrames > 0 ? (totalFrames * frameInfo.samplesPerFrame) / frameInfo.sampleRate : 0; + const avgBitrate = duration > 0 && totalBytes > 0 ? Math.round((totalBytes * 8) / duration / 1000) : frameInfo.bitrate; + return { + codecMode: "VBR (Xing)", + totalFrames, + duration, + bitrateKbps: avgBitrate, + }; +} +function parseMp3VbriHeader(view: DataView, offset: number, frameInfo: Mp3FrameInfo) { + if (offset + 18 > view.byteLength) + return null; + const totalBytes = view.getUint32(offset + 10, false); + const totalFrames = view.getUint32(offset + 14, false); + const duration = totalFrames > 0 ? (totalFrames * frameInfo.samplesPerFrame) / frameInfo.sampleRate : 0; + const bitrateKbps = duration > 0 && totalBytes > 0 ? Math.round((totalBytes * 8) / duration / 1000) : frameInfo.bitrate; + return { + codecMode: "VBR (VBRI)", + totalFrames, + duration, + bitrateKbps, + }; +} +function parseMp3VbrInfo(view: DataView, frameOffset: number, frameInfo: Mp3FrameInfo) { + const sideInfoSize = getMp3SideInfoSize(frameInfo); + const xingOffset = frameOffset + 4 + sideInfoSize; + if (xingOffset + 4 <= view.byteLength) { + const xingTag = String.fromCharCode(view.getUint8(xingOffset), view.getUint8(xingOffset + 1), view.getUint8(xingOffset + 2), view.getUint8(xingOffset + 3)); + if (xingTag === "Xing" || xingTag === "Info") { + return parseMp3XingHeader(view, xingOffset, frameInfo); + } + } + const vbriOffset = frameOffset + 36; + if (vbriOffset + 4 <= view.byteLength) { + const vbriTag = String.fromCharCode(view.getUint8(vbriOffset), view.getUint8(vbriOffset + 1), view.getUint8(vbriOffset + 2), view.getUint8(vbriOffset + 3)); + if (vbriTag === "VBRI") { + return parseMp3VbriHeader(view, vbriOffset, frameInfo); + } + } + return null; +} +function parseMp3Metadata(buffer: ArrayBuffer): ParsedAudioMetadata { + const view = new DataView(buffer); + const startOffset = skipId3v2Tag(view); + for (let offset = startOffset; offset <= view.byteLength - 4; offset++) { + const header = view.getUint32(offset, false); + const frameInfo = parseMp3FrameHeader(header); + if (frameInfo) { + const vbrInfo = parseMp3VbrInfo(view, offset, frameInfo); + const estimatedAudioDataSize = Math.max(0, view.byteLength - offset); + const estimatedFrameSize = frameInfo.frameSize > 0 ? frameInfo.frameSize : 1; + const totalFrames = vbrInfo?.totalFrames ?? Math.floor(estimatedAudioDataSize / estimatedFrameSize); + const duration = vbrInfo?.duration ?? ((totalFrames * frameInfo.samplesPerFrame) / frameInfo.sampleRate); + const bitrateKbps = vbrInfo?.bitrateKbps ?? frameInfo.bitrate; + return { + fileType: "MP3", + sampleRate: frameInfo.sampleRate, + channels: frameInfo.channels, + bitsPerSample: 16, + totalSamples: duration > 0 ? Math.floor(duration * frameInfo.sampleRate) : 0, + duration, + codecMode: vbrInfo?.codecMode ?? "CBR", + bitrateKbps, + totalFrames, + codecVersion: frameInfo.versionName, + }; + } + } + throw new Error("No valid MP3 frame found"); +} +function parseAacMetadata(buffer: ArrayBuffer): ParsedAudioMetadata { + const data = new Uint8Array(buffer); + for (let offset = 0; offset <= data.length - 7; offset++) { + if (data[offset] !== 0xff || (data[offset + 1] & 0xf6) !== 0xf0) + continue; + const sampleRateIndex = (data[offset + 2] >> 2) & 0x0f; + const sampleRate = AAC_SAMPLE_RATES[sampleRateIndex]; + const channels = ((data[offset + 2] & 0x01) << 2) | ((data[offset + 3] >> 6) & 0x03); + if (!sampleRate) + continue; + return { + fileType: "AAC", + sampleRate, + channels: channels || 2, + bitsPerSample: 16, + totalSamples: 0, + duration: 0, + }; + } + throw new Error("No valid AAC ADTS header found"); +} +function readMp4Box(view: DataView, offset: number, limit: number): Mp4BoxInfo | null { + if (offset + 8 > limit) + return null; + let size = view.getUint32(offset, false); + const type = readFourCC(view, offset + 4); + let headerSize = 8; + if (size === 1) { + if (offset + 16 > limit) + return null; + const high = view.getUint32(offset + 8, false); + const low = view.getUint32(offset + 12, false); + size = high * 4294967296 + low; + headerSize = 16; + } + else if (size === 0) { + size = limit - offset; + } + if (size < headerSize || offset + size > limit) + return null; + return { offset, size, headerSize, type }; +} +function parseM4aMetadata(buffer: ArrayBuffer): ParsedAudioMetadata { + const view = new DataView(buffer); + let sampleRate = 0; + let channels = 0; + let bitsPerSample = 0; + let duration = 0; + const scanBoxes = (start: number, end: number): void => { + let offset = start; + while (offset + 8 <= end) { + const box = readMp4Box(view, offset, end); + if (!box) + break; + const boxEnd = box.offset + box.size; + const contentStart = box.offset + box.headerSize; + if (box.type === "mdhd" && contentStart + 24 <= boxEnd) { + const version = view.getUint8(contentStart); + if (version === 0 && contentStart + 24 <= boxEnd) { + const timeScale = view.getUint32(contentStart + 12, false); + const durationValue = view.getUint32(contentStart + 16, false); + if (timeScale > 0) { + sampleRate = timeScale; + duration = durationValue / timeScale; + } + } + else if (version === 1 && contentStart + 36 <= boxEnd) { + const timeScale = view.getUint32(contentStart + 20, false); + const durationHigh = view.getUint32(contentStart + 24, false); + const durationLow = view.getUint32(contentStart + 28, false); + const durationValue = durationHigh * 4294967296 + durationLow; + if (timeScale > 0) { + sampleRate = timeScale; + duration = durationValue / timeScale; + } + } + } + else if ((box.type === "mp4a" || box.type === "aac ") && box.offset + 36 <= boxEnd) { + channels = view.getUint16(box.offset + 24, false) || channels; + bitsPerSample = view.getUint16(box.offset + 26, false) || bitsPerSample; + if (!sampleRate) { + const fixedPointSampleRate = view.getUint32(box.offset + 32, false); + if (fixedPointSampleRate > 0) { + sampleRate = Math.floor(fixedPointSampleRate / 65536); + } + } + } + if (MP4_CONTAINER_TYPES.has(box.type)) { + let childStart = contentStart; + if (box.type === "meta") + childStart = Math.min(boxEnd, contentStart + 4); + else if (box.type === "stsd") + childStart = Math.min(boxEnd, contentStart + 8); + if (childStart < boxEnd) + scanBoxes(childStart, boxEnd); + } + offset = boxEnd; + } + }; + scanBoxes(0, view.byteLength); + if (sampleRate <= 0) + sampleRate = 44100; + if (channels <= 0) + channels = 2; + if (bitsPerSample <= 0) + bitsPerSample = 16; + return { + fileType: "M4A", + sampleRate, + channels, + bitsPerSample, + totalSamples: duration > 0 ? Math.floor(duration * sampleRate) : 0, + duration, + }; +} +function parseAudioMetadata(input: AudioArrayBufferInput): ParsedAudioMetadata { + const fileType = detectAudioFileType(input.arrayBuffer, input.fileName); + switch (fileType) { + case "FLAC": return parseFlacMetadata(input.arrayBuffer); + case "MP3": return parseMp3Metadata(input.arrayBuffer); + case "M4A": return parseM4aMetadata(input.arrayBuffer); + case "AAC": return parseAacMetadata(input.arrayBuffer); + default: throw new Error(`Unsupported audio format: ${input.fileName || "unknown"}`); + } +} +function buildWindowCoefficients(size: number, windowFunction: SpectrumParams["windowFunction"]): Float32Array { + const coeffs = new Float32Array(size); + if (size <= 1) { + coeffs.fill(1); + return coeffs; + } + for (let i = 0; i < size; i++) { + switch (windowFunction) { + case "hamming": + coeffs[i] = 0.54 - 0.46 * Math.cos((2 * Math.PI * i) / (size - 1)); + break; + case "blackman": + coeffs[i] = + 0.42 - + 0.5 * Math.cos((2 * Math.PI * i) / (size - 1)) + + 0.08 * Math.cos((4 * Math.PI * i) / (size - 1)); + break; + case "rectangular": + coeffs[i] = 1; + break; + case "hann": + default: + coeffs[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (size - 1))); + break; + } + } + return coeffs; +} +function buildBitReversal(size: number): Uint32Array { + let bits = 0; + while ((1 << bits) < size) + bits++; + const out = new Uint32Array(size); + for (let i = 0; i < size; i++) { + let x = i; + let rev = 0; + for (let b = 0; b < bits; b++) { + rev = (rev << 1) | (x & 1); + x >>= 1; + } + out[i] = rev; + } + return out; +} +function fftInPlace(real: Float32Array, imag: Float32Array, bitReversal: Uint32Array): void { + const size = real.length; + for (let i = 1; i < size; i++) { + const j = bitReversal[i]; + if (i < j) { + const tr = real[i]; + real[i] = real[j]; + real[j] = tr; + const ti = imag[i]; + imag[i] = imag[j]; + imag[j] = ti; + } + } + for (let len = 2; len <= size; len <<= 1) { + const wLen = (-2 * Math.PI) / len; + const wLenReal = Math.cos(wLen); + const wLenImag = Math.sin(wLen); + for (let i = 0; i < size; i += len) { + let wReal = 1; + let wImag = 0; + const half = len >> 1; + for (let j = 0; j < half; j++) { + const uReal = real[i + j]; + const uImag = imag[i + j]; + const vReal = real[i + j + half] * wReal - imag[i + j + half] * wImag; + const vImag = real[i + j + half] * wImag + imag[i + j + half] * wReal; + real[i + j] = uReal + vReal; + imag[i + j] = uImag + vImag; + real[i + j + half] = uReal - vReal; + imag[i + j + half] = uImag - vImag; + const tempReal = wReal * wLenReal - wImag * wLenImag; + wImag = wReal * wLenImag + wImag * wLenReal; + wReal = tempReal; + } + } + } +} +export async function analyzeSpectrumFromSamples(samples: Float32Array, sampleRate: number, params: SpectrumParams, onProgress?: AnalysisProgressCallback, shouldCancel?: AnalysisCancelCheck): Promise { + throwIfCancelled(shouldCancel); + const fftSize = params.fftSize; + const hopSize = Math.max(1, Math.floor(fftSize / 4)); + const rawWindows = Math.floor((samples.length - fftSize) / hopSize); + const numWindows = Math.max(1, rawWindows); + const frameStride = Math.max(1, Math.ceil(numWindows / MAX_SPECTRUM_FRAMES)); + const freqBins = Math.floor(fftSize / 2) + 1; + const duration = sampleRate > 0 ? samples.length / sampleRate : 0; + const maxFreq = sampleRate / 2; + const windowCoeffs = buildWindowCoefficients(fftSize, params.windowFunction); + const bitReversal = buildBitReversal(fftSize); + const real = new Float32Array(fftSize); + const imag = new Float32Array(fftSize); + const invFFTSizeSquared = 1 / (fftSize * fftSize); + reportProgress(onProgress, "spectrum", 0, "Preparing FFT..."); + const windowIndices: number[] = []; + for (let windowIndex = 0; windowIndex < numWindows; windowIndex += frameStride) { + windowIndices.push(windowIndex); + } + if (windowIndices[windowIndices.length - 1] !== numWindows - 1) { + windowIndices.push(numWindows - 1); + } + const totalSlices = windowIndices.length; + const timeSlices: TimeSlice[] = new Array(totalSlices); + let lastReportedPercent = -1; + let lastYieldAt = nowMs(); + for (let i = 0; i < totalSlices; i++) { + throwIfCancelled(shouldCancel); + const windowIndex = windowIndices[i]; + const start = windowIndex * hopSize; + const remaining = samples.length - start; + const copyLen = Math.max(0, Math.min(fftSize, remaining)); + for (let j = 0; j < copyLen; j++) { + real[j] = samples[start + j] * windowCoeffs[j]; + imag[j] = 0; + } + for (let j = copyLen; j < fftSize; j++) { + real[j] = 0; + imag[j] = 0; + } + fftInPlace(real, imag, bitReversal); + const magnitudes = new Float32Array(freqBins); + for (let j = 0; j < freqBins; j++) { + const power = (real[j] * real[j] + imag[j] * imag[j]) * invFFTSizeSquared; + magnitudes[j] = power > 1e-12 ? 10 * Math.log10(power) : -120; + } + timeSlices[i] = { + time: sampleRate > 0 ? start / sampleRate : 0, + magnitudes, + }; + const currentPercent = Math.floor(((i + 1) / totalSlices) * 100); + if (currentPercent > lastReportedPercent) { + lastReportedPercent = currentPercent; + reportProgress(onProgress, "spectrum", currentPercent, "Analyzing spectrum..."); + } + if ((i + 1) % 8 === 0) { + const now = nowMs(); + if (now - lastYieldAt >= 16) { + await nextTick(); + lastYieldAt = nowMs(); + throwIfCancelled(shouldCancel); + } + } + } + reportProgress(onProgress, "spectrum", 100, "Spectrum analysis complete"); + return { + time_slices: timeSlices, + sample_rate: sampleRate, + freq_bins: freqBins, + duration, + max_freq: maxFreq, + }; +} +function createAnalysisAudioContext(sampleRate: number): AudioContext { + if (sampleRate > 0) { + try { + return new AudioContext({ sampleRate }); + } + catch { + return new AudioContext(); + } + } + return new AudioContext(); +} +export async function analyzeAudioFile(file: File, params: SpectrumParams = DEFAULT_PARAMS, onProgress?: AnalysisProgressCallback, shouldCancel?: AnalysisCancelCheck): Promise { + throwIfCancelled(shouldCancel); + reportProgress(onProgress, "read", 2, "Reading file..."); + const arrayBuffer = await file.arrayBuffer(); + throwIfCancelled(shouldCancel); + reportProgress(onProgress, "read", 10, "File loaded"); + return analyzeAudioArrayBuffer({ + fileName: file.name, + fileSize: file.size, + arrayBuffer, + }, params, (progress) => { + const mappedPercent = 10 + (progress.percent * 0.9); + reportProgress(onProgress, progress.phase, mappedPercent, progress.message); + }, shouldCancel); +} +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); + throwIfCancelled(shouldCancel); + reportProgress(onProgress, "decode", 15, "Decoding audio stream..."); + const audioContext = createAnalysisAudioContext(metadata.sampleRate); + try { + const audioBuffer = await audioContext.decodeAudioData(input.arrayBuffer.slice(0)); + 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; + } + finally { + await audioContext.close(); + } +} +export const analyzeFlacFile = analyzeAudioFile; +export const analyzeFlacArrayBuffer = analyzeAudioArrayBuffer; diff --git a/frontend/src/lib/preview.ts b/frontend/src/lib/preview.ts new file mode 100644 index 0000000..a82621d --- /dev/null +++ b/frontend/src/lib/preview.ts @@ -0,0 +1 @@ +export const SPOTIFY_PREVIEW_VOLUME = 1; diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index d5bb8d4..0920b4e 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -112,7 +112,7 @@ export const DEFAULT_SETTINGS: Settings = { autoQuality: "16", allowFallback: true, useSpotFetchAPI: false, - spotFetchAPIUrl: "https://spotify.afkarxyz.fun/api", + spotFetchAPIUrl: "https://sp.afkarxyz.qzz.io/api", createPlaylistFolder: true, createM3u8File: false, useFirstArtistOnly: false, diff --git a/frontend/src/lib/toast-with-sound.ts b/frontend/src/lib/toast-with-sound.ts index 1fcb30b..91e46fe 100644 --- a/frontend/src/lib/toast-with-sound.ts +++ b/frontend/src/lib/toast-with-sound.ts @@ -5,41 +5,49 @@ import { getSettings } from "./settings"; const toastStyle = { className: "font-mono lowercase", }; +type ToastData = Parameters[1]; const isSfxEnabled = () => getSettings().sfxEnabled; export const toastWithSound = { - success: (message: string, data?: any) => { + success: (message: string, data?: ToastData) => { const msg = message.toLowerCase(); logger.success(msg); if (isSfxEnabled()) playSuccessSound(); return toast.success(msg, { ...toastStyle, ...data }); }, - error: (message: string, data?: any) => { + error: (message: string, data?: ToastData) => { const msg = message.toLowerCase(); logger.error(msg); if (isSfxEnabled()) playErrorSound(); return toast.error(msg, { ...toastStyle, ...data }); }, - warning: (message: string, data?: any) => { + warning: (message: string, data?: ToastData) => { const msg = message.toLowerCase(); logger.warning(msg); if (isSfxEnabled()) playWarningSound(); return toast.warning(msg, { ...toastStyle, ...data }); }, - info: (message: string, data?: any) => { + info: (message: string, data?: ToastData) => { const msg = message.toLowerCase(); logger.info(msg); if (isSfxEnabled()) playInfoSound(); return toast.info(msg, { ...toastStyle, ...data }); }, - message: (message: string, data?: any) => { + message: (message: string, data?: ToastData) => { const msg = message.toLowerCase(); logger.info(msg); if (isSfxEnabled()) playInfoSound(); return toast(msg, { ...toastStyle, ...data }); }, + silentInfo: (message: string, data?: ToastData) => { + const msg = message.toLowerCase(); + logger.info(msg); + return toast.info(msg, { ...toastStyle, ...data }); + }, + dismiss: (id?: string | number) => toast.dismiss(id), + toast: toast, }; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index b809493..03cd42d 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -155,7 +155,7 @@ export interface HealthResponse { } export interface TimeSlice { time: number; - magnitudes: number[]; + magnitudes: number[] | Float32Array; } export interface SpectrumData { time_slices: TimeSlice[]; @@ -167,6 +167,7 @@ export interface SpectrumData { export interface AnalysisResult { file_path: string; file_size: number; + file_type?: "FLAC" | "MP3" | "M4A" | "AAC"; sample_rate: number; channels: number; bits_per_sample: number; @@ -176,6 +177,10 @@ export interface AnalysisResult { dynamic_range: number; peak_amplitude: number; rms_level: number; + codec_mode?: string; + bitrate_kbps?: number; + total_frames?: number; + codec_version?: string; spectrum?: SpectrumData; } export interface LyricsDownloadRequest { diff --git a/go.mod b/go.mod index 640ceb3..6795b5d 100644 --- a/go.mod +++ b/go.mod @@ -7,11 +7,11 @@ require ( github.com/go-flac/flacpicture v0.3.0 github.com/go-flac/flacvorbis v0.2.0 github.com/go-flac/go-flac v1.0.0 - github.com/mewkiz/flac v1.0.13 github.com/pquerna/otp v1.5.0 github.com/ulikunitz/xz v0.5.15 github.com/wailsapp/wails/v2 v2.11.0 go.etcd.io/bbolt v1.4.3 + golang.org/x/image v0.12.0 golang.org/x/text v0.31.0 ) @@ -22,7 +22,6 @@ require ( github.com/godbus/dbus/v5 v5.2.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect - github.com/icza/bitio v1.1.0 // indirect github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect github.com/labstack/echo/v4 v4.13.4 // indirect github.com/labstack/gommon v0.4.2 // indirect @@ -32,8 +31,6 @@ require ( github.com/leaanthony/u v1.1.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect - github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect diff --git a/go.sum b/go.sum index abeb5a9..58a355f 100644 --- a/go.sum +++ b/go.sum @@ -21,10 +21,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0= -github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= -github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k= -github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= @@ -48,12 +44,6 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs= -github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k= -github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU= -github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI= -github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8= -github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -92,15 +82,20 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/image v0.12.0 h1:w13vZbU4o5rKOFFR8y7M+c4A5jXDC0uXTdHYRP8X2DQ= +golang.org/x/image v0.12.0/go.mod h1:Lu90jvHG7GfemOIcldsh9A2hS01ocl6oNO7ype5mEnk= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -111,21 +106,26 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/wails.json b/wails.json index a412dff..3ea5222 100644 --- a/wails.json +++ b/wails.json @@ -12,7 +12,7 @@ }, "info": { "productName": "SpotiFLAC", - "productVersion": "7.1.1", + "productVersion": "7.1.2", "copyright": "© 2026 afkarxyz" }, "wailsjsdir": "./frontend",