package download import ( "bytes" "context" "errors" "fmt" "io" "net/http" "net/url" "regexp" "strconv" "strings" "github.com/PuerkitoBio/goquery" ) var ( bpmRegex = regexp.MustCompile(`tempo of ]*>(\d+) BPM`) keyRegex = regexp.MustCompile(`with a ]*>([A-G](?:♯|#|♭|b)?(?:/[A-G](?:♯|#|♭|b)?)?) key`) modeRegex = regexp.MustCompile(`a ]*>([a-z]+) mode`) ) func fetchBPM(ctx context.Context, httpClient *http.Client, artist, title, duration string) (bpmKey, error) { trackURL, err := findTrackURL(ctx, httpClient, artist, title, duration) if err != nil { return bpmKey{}, err } html, err := fetchBPMPage(ctx, httpClient, trackURL) if err != nil { return bpmKey{}, err } return parseBPM(html) } func findTrackURL(ctx context.Context, httpClient *http.Client, artist, title, duration string) (string, error) { const rootURL = "https://songbpm.com" values := url.Values{} values.Add("query", fmt.Sprintf("%s %s", artist, title)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, rootURL+"/searches", bytes.NewBufferString(values.Encode())) if err != nil { return "", err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", rootURL) resp, err := httpClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) } doc, err := goquery.NewDocumentFromReader(resp.Body) if err != nil { return "", err } wantDuration, err := strconv.Atoi(duration) if err != nil { return "", fmt.Errorf("invalid duration: %w", err) } lowerTitle := strings.ToLower(title) lowerArtist := strings.ToLower(artist) var matchURL string doc.Find("a.flex.flex-col").EachWithBreak(func(_ int, sel *goquery.Selection) bool { text := strings.ToLower(sel.Text()) if !strings.Contains(text, lowerTitle) || !strings.Contains(text, lowerArtist) { return true } durationStr := strings.TrimSpace(sel.Find("div.flex-1.flex-col.items-center").Eq(1).Find("span.text-2xl").Text()) parts := strings.Split(durationStr, ":") if len(parts) != 2 { return true } minutes, err := strconv.Atoi(parts[0]) if err != nil { return true } seconds, err := strconv.Atoi(parts[1]) if err != nil { return true } const toleranceSec = 2 foundDuration := minutes*60 + seconds if foundDuration < wantDuration-toleranceSec || foundDuration > wantDuration+toleranceSec { return true } matchURL = sel.AttrOr("href", "") return false }) if matchURL == "" { return "", errors.New("no data found") } return rootURL + matchURL, nil } func fetchBPMPage(ctx context.Context, httpClient *http.Client, pageURL string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil) if err != nil { return "", err } resp, err := httpClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return "", err } return string(body), nil } func parseBPM(html string) (bpmKey, error) { bpmMatch := bpmRegex.FindStringSubmatch(html) keyMatch := keyRegex.FindStringSubmatch(html) modeMatch := modeRegex.FindStringSubmatch(html) if len(bpmMatch) != 2 || len(keyMatch) != 2 || len(modeMatch) != 2 { return bpmKey{}, errors.New("no data found") } bpm := bpmMatch[1] key := strings.SplitN(keyMatch[1], "/", 2)[0] key = strings.ReplaceAll(key, "\u266f", "#") key = strings.ReplaceAll(key, "\u266d", "b") if modeMatch[1] == "minor" { key += "m" } return bpmKey{BPM: bpm, Key: key}, nil }