1725 lines
52 KiB
Go
1725 lines
52 KiB
Go
// Package main implements a powerful dual-source Guitar Tab Downloader CLI utility.
|
|
// It integrates with GProTab.net (via custom HTML scraping) and Songsterr.com (via automated Node-based scraping and conversion).
|
|
// Supports full interactive console UI (TUI) mode, automated headless downloads, pretty colorized terminal tables,
|
|
// polite rate-limiting delays, and automatic fallback search trees when the primary site encounters issues.
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
baseURL = "https://gprotab.net"
|
|
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
)
|
|
|
|
// ANSI color codes
|
|
const (
|
|
colorReset = "\033[0m"
|
|
colorRed = "\033[31m"
|
|
colorGreen = "\033[32m"
|
|
colorYellow = "\033[33m"
|
|
colorBlue = "\033[34m"
|
|
colorCyan = "\033[36m"
|
|
colorBold = "\033[1m"
|
|
colorGray = "\033[90m"
|
|
colorWhite = "\033[97m"
|
|
)
|
|
|
|
// Artist represents an artist metadata entry retrieved from the tab databases.
|
|
type Artist struct {
|
|
Name string // The human-readable name of the artist/band
|
|
URL string // The relative or absolute path to the artist's tab directory page
|
|
}
|
|
|
|
// Song represents an individual song/track metadata entry retrieved from search engines.
|
|
type Song struct {
|
|
Name string // The name of the song
|
|
ArtistName string // The name of the performing artist
|
|
URL string // The absolute or relative tab file download/details page URL
|
|
ArtistURL string // The URL linking back to the parent artist details page
|
|
}
|
|
|
|
// SongsterrTrack maps the raw JSON response returned by the Songsterr API search endpoint.
|
|
type SongsterrTrack struct {
|
|
SongID int `json:"songId"` // Global unique integer identifier for the song on Songsterr
|
|
Artist string `json:"artist"` // Band or musician name
|
|
Title string `json:"title"` // Track name
|
|
}
|
|
|
|
// printHeader prints a styled block header with bold cyan/blue ANSI colors in the console.
|
|
func printHeader(text string) {
|
|
fmt.Printf("\n%s=== %s ===%s\n", colorBlue+colorBold, text, colorReset)
|
|
}
|
|
|
|
// printSuccess writes a positive status message prefixed with [+] in bright green to stdout.
|
|
func printSuccess(format string, a ...interface{}) {
|
|
msg := fmt.Sprintf(format, a...)
|
|
fmt.Printf("%s[+] %s%s\n", colorGreen, msg, colorReset)
|
|
}
|
|
|
|
// printInfo writes an informational status message prefixed with [*] in bright cyan to stdout.
|
|
func printInfo(format string, a ...interface{}) {
|
|
msg := fmt.Sprintf(format, a...)
|
|
fmt.Printf("%s[*] %s%s\n", colorCyan, msg, colorReset)
|
|
}
|
|
|
|
// printWarning writes a warning or attention alert prefixed with [!] in bright yellow to stdout.
|
|
func printWarning(format string, a ...interface{}) {
|
|
msg := fmt.Sprintf(format, a...)
|
|
fmt.Printf("%s[!] %s%s\n", colorYellow, msg, colorReset)
|
|
}
|
|
|
|
// printError writes a critical error status message prefixed with [-] in bright red to stdout.
|
|
func printError(format string, a ...interface{}) {
|
|
msg := fmt.Sprintf(format, a...)
|
|
fmt.Printf("%s[-] Error: %s%s\n", colorRed, msg, colorReset)
|
|
}
|
|
|
|
// normalizePath parses and standardizes mixed user inputs (full URLs, partial paths, or slugs)
|
|
// into a clean relative path usable by the GProTab.net HTTP scraping engine.
|
|
// Example: "https://gprotab.net/en/tabs/metallica/one" becomes "/en/tabs/metallica/one".
|
|
func normalizePath(input string) string {
|
|
input = strings.TrimSpace(input)
|
|
if strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://") {
|
|
u, err := url.Parse(input)
|
|
if err == nil {
|
|
return u.Path
|
|
}
|
|
}
|
|
if strings.HasPrefix(input, "/") {
|
|
return input
|
|
}
|
|
// If it contains a slash but no prefix, assume it's a partial artist/song slug
|
|
if strings.Contains(input, "/") {
|
|
return "/en/tabs/" + input
|
|
}
|
|
return input
|
|
}
|
|
|
|
// sanitizeDirName strips or replaces illegal file system characters from a name
|
|
// while preserving its capitalization, spelling, and spacing.
|
|
func sanitizeDirName(name string) string {
|
|
name = strings.TrimSpace(name)
|
|
// Replace standard illegal file system characters with an empty string
|
|
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
|
cleaned := reg.ReplaceAllString(name, "")
|
|
// Collapse multiple spaces
|
|
regSpace := regexp.MustCompile(`\s+`)
|
|
cleaned = regSpace.ReplaceAllString(cleaned, " ")
|
|
return strings.TrimSpace(cleaned)
|
|
}
|
|
|
|
// isTargetArtist checks if the song's artist name matches the requested artist name.
|
|
// It handles case-insensitivity, trailing commas, squished names, and multiple split collaborators (comma, "feat.", "ft.", "and", "&").
|
|
func isTargetArtist(songArtist, targetArtist string) bool {
|
|
songArtistLower := strings.ToLower(strings.TrimSpace(songArtist))
|
|
targetLower := strings.ToLower(strings.TrimSpace(targetArtist))
|
|
|
|
// Direct case-insensitive match
|
|
if songArtistLower == targetLower {
|
|
return true
|
|
}
|
|
|
|
// Helper to clean alphanumeric only
|
|
clean := func(s string) string {
|
|
reg := regexp.MustCompile(`[^a-z0-9]`)
|
|
return reg.ReplaceAllString(s, "")
|
|
}
|
|
|
|
cleanSong := clean(songArtistLower)
|
|
cleanTarget := clean(targetLower)
|
|
|
|
// Match if alphanumeric characters are identical (e.g. "augustburnsred" == "augustburnsred")
|
|
if cleanSong == cleanTarget {
|
|
return true
|
|
}
|
|
|
|
// Split by common collaborator separators
|
|
separators := []string{",", " feat. ", " feat ", " ft. ", " ft ", " and ", " & "}
|
|
parts := []string{songArtistLower}
|
|
|
|
for _, sep := range separators {
|
|
var nextParts []string
|
|
for _, p := range parts {
|
|
split := strings.Split(p, sep)
|
|
for _, s := range split {
|
|
trimmed := strings.TrimSpace(s)
|
|
if trimmed != "" {
|
|
nextParts = append(nextParts, trimmed)
|
|
}
|
|
}
|
|
}
|
|
parts = nextParts
|
|
}
|
|
|
|
// Check if any of the collaborator parts matches the target artist name (directly or in clean alphanumeric)
|
|
for _, part := range parts {
|
|
if part == targetLower || clean(part) == cleanTarget {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// fetchHTML performs a standard HTTP GET request against a target path or URL,
|
|
// injecting a polite User-Agent header, and returns the response body as a string.
|
|
func fetchHTML(path string) (string, error) {
|
|
fullURL := baseURL + path
|
|
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
|
fullURL = path
|
|
}
|
|
|
|
req, err := http.NewRequest("GET", fullURL, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("User-Agent", userAgent)
|
|
|
|
// Ensure requests do not hang indefinitely by enforcing a 15-second timeout
|
|
client := &http.Client{Timeout: 15 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("HTTP status %d", resp.StatusCode)
|
|
}
|
|
|
|
bodyBytes, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return string(bodyBytes), nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// GProTab.net Functions
|
|
// ============================================================================
|
|
|
|
// searchArtists performs a search on GProTab.net for artists matching the given query string.
|
|
// It parses GProTab's specific HTML search results using regular expressions and extracts matching artist metadata.
|
|
func searchArtists(query string) ([]Artist, error) {
|
|
escapedQuery := url.QueryEscape(query)
|
|
searchURL := fmt.Sprintf("/en/search?type=artist&q=%s", escapedQuery)
|
|
|
|
html, err := fetchHTML(searchURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetching HTML for artist search %q: %w", query, err)
|
|
}
|
|
|
|
// Extract the main ordered list wrapper containing artist results
|
|
artistListRegex := regexp.MustCompile(`<ol class="artists">([\s\S]*?)</ol>`)
|
|
match := artistListRegex.FindStringSubmatch(html)
|
|
if len(match) < 2 {
|
|
return nil, nil
|
|
}
|
|
|
|
artistBlock := match[1]
|
|
// Match individual hyperlinks containing the relative URL path and the artist name
|
|
artistRegex := regexp.MustCompile(`<a href="(/en/tabs/[^"]+)">([^<]+)</a>`)
|
|
matches := artistRegex.FindAllStringSubmatch(artistBlock, -1)
|
|
|
|
var artists []Artist
|
|
for _, m := range matches {
|
|
if len(m) >= 3 {
|
|
artists = append(artists, Artist{
|
|
URL: m[1],
|
|
Name: strings.TrimSpace(m[2]),
|
|
})
|
|
}
|
|
}
|
|
|
|
return artists, nil
|
|
}
|
|
|
|
// searchSongs queries GProTab.net for song tracks matching the search query.
|
|
// It utilizes regex matching on individual ".tab-data" blocks to capture song title, URL, artist, and artist URL.
|
|
func searchSongs(query string) ([]Song, error) {
|
|
escapedQuery := url.QueryEscape(query)
|
|
searchURL := fmt.Sprintf("/en/search?type=song&q=%s", escapedQuery)
|
|
|
|
html, err := fetchHTML(searchURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetching HTML for song search %q: %w", query, err)
|
|
}
|
|
|
|
// Regexp matches structured HTML results containing band and song anchor details
|
|
tabDataRegex := regexp.MustCompile(`<div class="tab-data">[\s\S]*?<a href="([^"]+)" class="tab-band">([^<]+)</a>[\s\S]*?<a href="([^"]+)" class="tab-name">([^<]+)</a>`)
|
|
matches := tabDataRegex.FindAllStringSubmatch(html, -1)
|
|
|
|
var songs []Song
|
|
for _, m := range matches {
|
|
if len(m) >= 5 {
|
|
songs = append(songs, Song{
|
|
ArtistURL: m[1],
|
|
ArtistName: strings.TrimSpace(m[2]),
|
|
URL: m[3],
|
|
Name: strings.TrimSpace(m[4]),
|
|
})
|
|
}
|
|
}
|
|
|
|
return songs, nil
|
|
}
|
|
|
|
// fetchArtistTabs retrieves all individual guitar tabs listed on an artist's GProTab.net folder page.
|
|
// It formats the local slug (e.g. "led-zeppelin" -> "Led Zeppelin") to populate the artist field.
|
|
func fetchArtistTabs(artistURL string) ([]Song, error) {
|
|
html, err := fetchHTML(artistURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetching HTML for artist tabs %q: %w", artistURL, err)
|
|
}
|
|
|
|
// Matches list item hyperlinks pointing to actual tab sheets
|
|
artistTabRegex := regexp.MustCompile(`<li><a href="(/en/tabs/[^/]+/[^"]+)">([^<]+)</a></li>`)
|
|
matches := artistTabRegex.FindAllStringSubmatch(html, -1)
|
|
|
|
// Format artist slug into printable title casing
|
|
artistSlug := filepath.Base(artistURL)
|
|
words := strings.Split(artistSlug, "-")
|
|
for i, w := range words {
|
|
if len(w) > 0 {
|
|
words[i] = strings.ToUpper(w[:1]) + w[1:]
|
|
}
|
|
}
|
|
artistName := strings.Join(words, " ")
|
|
|
|
var songs []Song
|
|
for _, m := range matches {
|
|
if len(m) >= 3 {
|
|
songs = append(songs, Song{
|
|
URL: m[1],
|
|
Name: strings.TrimSpace(m[2]),
|
|
ArtistURL: artistURL,
|
|
ArtistName: artistName,
|
|
})
|
|
}
|
|
}
|
|
|
|
return songs, nil
|
|
}
|
|
|
|
// downloadTab handles fetching a specific tab file from GProTab.net and saving it locally.
|
|
// It parses the server's Content-Disposition header to resolve the authentic, original filename.
|
|
func downloadTab(tabURL string, outputDir string, delay time.Duration) (string, error) {
|
|
// Respect polite request rate limit delays
|
|
if delay > 0 {
|
|
time.Sleep(delay)
|
|
}
|
|
|
|
downloadURL := baseURL + tabURL + "?download"
|
|
if strings.HasPrefix(tabURL, "http://") || strings.HasPrefix(tabURL, "https://") {
|
|
downloadURL = tabURL + "?download"
|
|
if strings.Contains(tabURL, "?") {
|
|
downloadURL = tabURL + "&download"
|
|
}
|
|
}
|
|
|
|
req, err := http.NewRequest("GET", downloadURL, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("User-Agent", userAgent)
|
|
|
|
// Broaden timeout for downloading files
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("HTTP status %d", resp.StatusCode)
|
|
}
|
|
|
|
var filename string
|
|
// Attempt to parse standard HTTP disposition headers to keep exact filename extensions (.gp, .gp5, etc.)
|
|
disposition := resp.Header.Get("Content-Disposition")
|
|
if disposition != "" {
|
|
_, params, err := mime.ParseMediaType(disposition)
|
|
if err == nil {
|
|
filename = params["filename"]
|
|
}
|
|
}
|
|
|
|
// Fallback if no Content-Disposition filename is present on response headers
|
|
if filename == "" {
|
|
u, err := url.Parse(tabURL)
|
|
if err == nil {
|
|
filename = filepath.Base(u.Path)
|
|
if filename == "" || filename == "." || filename == "/" {
|
|
filename = "tab.gp"
|
|
} else {
|
|
filename = filename + ".gp"
|
|
}
|
|
} else {
|
|
filename = "tab.gp"
|
|
}
|
|
}
|
|
|
|
// Avoid directory traversal attacks by securing filename paths
|
|
filename = filepath.Base(filename)
|
|
|
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
outPath := filepath.Join(outputDir, filename)
|
|
outFile, err := os.Create(outPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer outFile.Close()
|
|
|
|
_, err = io.Copy(outFile, resp.Body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return outPath, nil
|
|
}
|
|
|
|
// parseSelection parses comma-separated list of indices and index ranges,
|
|
// e.g. "1,3,5" or "1-5,8,10-12", and returns a sorted slice of 0-based unique indices.
|
|
func parseSelection(input string, maxVal int) ([]int, error) {
|
|
input = strings.TrimSpace(input)
|
|
if input == "" {
|
|
return nil, fmt.Errorf("empty selection")
|
|
}
|
|
|
|
selectedMap := make(map[int]bool)
|
|
parts := strings.Split(input, ",")
|
|
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
|
|
if strings.Contains(part, "-") {
|
|
rangeParts := strings.Split(part, "-")
|
|
if len(rangeParts) != 2 {
|
|
return nil, fmt.Errorf("invalid range format %q", part)
|
|
}
|
|
startStr := strings.TrimSpace(rangeParts[0])
|
|
endStr := strings.TrimSpace(rangeParts[1])
|
|
|
|
start, err := strconv.Atoi(startStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid number %q in range %q", startStr, part)
|
|
}
|
|
end, err := strconv.Atoi(endStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid number %q in range %q", endStr, part)
|
|
}
|
|
|
|
if start > end {
|
|
start, end = end, start
|
|
}
|
|
|
|
if start < 1 || end > maxVal {
|
|
return nil, fmt.Errorf("selection %d-%d out of bounds (1-%d)", start, end, maxVal)
|
|
}
|
|
|
|
for i := start; i <= end; i++ {
|
|
selectedMap[i-1] = true
|
|
}
|
|
} else {
|
|
val, err := strconv.Atoi(part)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid number %q", part)
|
|
}
|
|
if val < 1 || val > maxVal {
|
|
return nil, fmt.Errorf("selection %d out of bounds (1-%d)", val, maxVal)
|
|
}
|
|
selectedMap[val-1] = true
|
|
}
|
|
}
|
|
|
|
if len(selectedMap) == 0 {
|
|
return nil, fmt.Errorf("no valid selections")
|
|
}
|
|
|
|
var selected []int
|
|
for k := range selectedMap {
|
|
selected = append(selected, k)
|
|
}
|
|
|
|
sort.Ints(selected)
|
|
return selected, nil
|
|
}
|
|
|
|
// promptSelectSongs displays a list of songs, prompts the user to select which ones they want,
|
|
// and returns the selected subset. If the user chooses "all" (or just presses Enter), all songs are returned.
|
|
func promptSelectSongs(songs []Song) ([]Song, error) {
|
|
printSongsTable(songs)
|
|
|
|
fmt.Printf("\n%sEnter selection (e.g., 1-5,7,10-12) or press Enter to download all [Default: all]: %s", colorYellow, colorReset)
|
|
|
|
reader := bufio.NewReader(os.Stdin)
|
|
choiceStr, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
// Non-interactive fallback
|
|
printInfo("Non-interactive or EOF detected. Downloading all songs by default.")
|
|
return songs, nil
|
|
}
|
|
|
|
choiceStr = strings.TrimSpace(choiceStr)
|
|
if choiceStr == "" || strings.ToLower(choiceStr) == "all" {
|
|
printInfo("Downloading all songs.")
|
|
return songs, nil
|
|
}
|
|
|
|
indices, err := parseSelection(choiceStr, len(songs))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var selectedSongs []Song
|
|
for _, idx := range indices {
|
|
selectedSongs = append(selectedSongs, songs[idx])
|
|
}
|
|
|
|
return selectedSongs, nil
|
|
}
|
|
|
|
// downloadArtistTabs triggers a batch sequential download of all tabs matching an artist on GProTab.net.
|
|
// Automatically rate-limits request threads using polite millisecond delays between fetches.
|
|
func downloadArtistTabs(artistURL string, outputDir string, delay time.Duration) error {
|
|
artistURL = normalizePath(artistURL)
|
|
printInfo("Fetching tabs list for artist: %s", artistURL)
|
|
|
|
songs, err := fetchArtistTabs(artistURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(songs) == 0 {
|
|
return fmt.Errorf("no tabs found for artist at %s", artistURL)
|
|
}
|
|
|
|
printSuccess("Found %d tabs for this artist.", len(songs))
|
|
|
|
// Prompt user for selection (defaulting to all)
|
|
selectedSongs, err := promptSelectSongs(songs)
|
|
if err != nil {
|
|
return fmt.Errorf("selecting songs: %w", err)
|
|
}
|
|
|
|
artistDirName := sanitizeDirName(songs[0].ArtistName)
|
|
if artistDirName == "" {
|
|
artistDirName = filepath.Base(artistURL)
|
|
}
|
|
artistDir := filepath.Join(outputDir, artistDirName)
|
|
printInfo("Downloading tabs to: %s", artistDir)
|
|
|
|
for i, song := range selectedSongs {
|
|
fmt.Printf("[%d/%d] Downloading: %s\n", i+1, len(selectedSongs), song.Name)
|
|
savedPath, err := downloadTab(song.URL, artistDir, delay)
|
|
if err != nil {
|
|
printError("Failed to download %s: %v", song.Name, err)
|
|
continue
|
|
}
|
|
printSuccess("Saved to: %s", savedPath)
|
|
}
|
|
|
|
printSuccess("Finished downloading artist tabs!")
|
|
return nil
|
|
}
|
|
|
|
// resolveArtistURL checks if the input is already a valid artist path slug.
|
|
// If not, it executes a search query and allows the user to interactively select the correct match,
|
|
// or falls back to selecting the first best result in headless/non-interactive mode.
|
|
func resolveArtistURL(input string, interactive bool) (string, error) {
|
|
normalized := normalizePath(input)
|
|
if strings.HasPrefix(normalized, "/en/tabs/") {
|
|
return normalized, nil
|
|
}
|
|
|
|
artists, err := searchArtists(input)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if len(artists) == 0 {
|
|
return "", fmt.Errorf("no artist found matching '%s'", input)
|
|
}
|
|
|
|
if len(artists) == 1 {
|
|
return artists[0].URL, nil
|
|
}
|
|
|
|
if !interactive {
|
|
printWarning("Multiple artists found. Automatically selecting the first match: %s (%s)", artists[0].Name, artists[0].URL)
|
|
return artists[0].URL, nil
|
|
}
|
|
|
|
printWarning("Multiple artists found:")
|
|
for i, a := range artists {
|
|
fmt.Printf(" [%d] %s (%s)\n", i+1, a.Name, a.URL)
|
|
}
|
|
|
|
reader := bufio.NewReader(os.Stdin)
|
|
for {
|
|
fmt.Printf("%sSelect artist number (1-%d): %s", colorYellow, len(artists), colorReset)
|
|
choiceStr, _ := reader.ReadString('\n')
|
|
choiceStr = strings.TrimSpace(choiceStr)
|
|
idx, err := strconv.Atoi(choiceStr)
|
|
if err == nil && idx >= 1 && idx <= len(artists) {
|
|
return artists[idx-1].URL, nil
|
|
}
|
|
printError("Invalid choice. Please choose a number between 1 and %d", len(artists))
|
|
}
|
|
}
|
|
|
|
// resolveSongURL checks if the input is already a valid multi-level song path slug.
|
|
// If not, it executes a search query and allows interactive or automated match selection.
|
|
func resolveSongURL(input string, interactive bool) (string, error) {
|
|
normalized := normalizePath(input)
|
|
if strings.HasPrefix(normalized, "/en/tabs/") {
|
|
parts := strings.Split(strings.Trim(normalized, "/"), "/")
|
|
if len(parts) >= 4 {
|
|
return normalized, nil
|
|
}
|
|
}
|
|
|
|
songs, err := searchSongs(input)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if len(songs) == 0 {
|
|
return "", fmt.Errorf("no song found matching '%s'", input)
|
|
}
|
|
|
|
if len(songs) == 1 {
|
|
return songs[0].URL, nil
|
|
}
|
|
|
|
if !interactive {
|
|
printWarning("Multiple songs found. Automatically selecting the first match: %s (by %s)", songs[0].Name, songs[0].ArtistName)
|
|
return songs[0].URL, nil
|
|
}
|
|
|
|
printWarning("Multiple songs found:")
|
|
for i, s := range songs {
|
|
fmt.Printf(" [%d] %s (by %s)\n", i+1, s.Name, s.ArtistName)
|
|
}
|
|
|
|
reader := bufio.NewReader(os.Stdin)
|
|
for {
|
|
fmt.Printf("%sSelect song number (1-%d): %s", colorYellow, len(songs), colorReset)
|
|
choiceStr, _ := reader.ReadString('\n')
|
|
choiceStr = strings.TrimSpace(choiceStr)
|
|
idx, err := strconv.Atoi(choiceStr)
|
|
if err == nil && idx >= 1 && idx <= len(songs) {
|
|
return songs[idx-1].URL, nil
|
|
}
|
|
printError("Invalid choice. Please choose a number between 1 and %d", len(songs))
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Songsterr.com Functions
|
|
// ============================================================================
|
|
|
|
// cleanSongsterrName formats string inputs into Songsterr-compatible URL slugs.
|
|
// It maps uppercase characters to lowercase, swaps out non-alphanumeric characters with hyphens,
|
|
// and collapses duplicates to maintain clean routing paths.
|
|
func cleanSongsterrName(input string) string {
|
|
input = strings.ToLower(input)
|
|
// Replace non-alphanumeric with hyphens
|
|
reg := regexp.MustCompile(`[^a-z0-9]`)
|
|
cleaned := reg.ReplaceAllString(input, "-")
|
|
// Collapse multiple hyphens
|
|
regCollapse := regexp.MustCompile(`-+`)
|
|
cleaned = regCollapse.ReplaceAllString(cleaned, "-")
|
|
return strings.Trim(cleaned, "-")
|
|
}
|
|
|
|
// searchSongsterr searches Songsterr's REST API for songs matching a text query.
|
|
// It maps returned entries into a standard unified []Song list by constructing direct tab page URLs.
|
|
func searchSongsterr(query string) ([]Song, error) {
|
|
escapedQuery := url.QueryEscape(query)
|
|
var songs []Song
|
|
from := 0
|
|
size := 250
|
|
|
|
for {
|
|
searchURL := fmt.Sprintf("https://www.songsterr.com/api/songs?size=%d&from=%d&pattern=%s", size, from, escapedQuery)
|
|
|
|
req, err := http.NewRequest("GET", searchURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating HTTP request for Songsterr query %q: %w", query, err)
|
|
}
|
|
req.Header.Set("User-Agent", userAgent)
|
|
|
|
client := &http.Client{Timeout: 15 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("executing HTTP request for Songsterr query %q: %w", query, err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
resp.Body.Close()
|
|
return nil, fmt.Errorf("HTTP status %d", resp.StatusCode)
|
|
}
|
|
|
|
bodyBytes, err := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading response body for Songsterr query %q: %w", query, err)
|
|
}
|
|
|
|
var rawSongs []SongsterrTrack
|
|
if err := json.Unmarshal(bodyBytes, &rawSongs); err != nil {
|
|
return nil, fmt.Errorf("unmarshaling JSON for Songsterr query %q: %w", query, err)
|
|
}
|
|
|
|
if len(rawSongs) == 0 {
|
|
break
|
|
}
|
|
|
|
for _, s := range rawSongs {
|
|
cleanArtist := cleanSongsterrName(s.Artist)
|
|
cleanTitle := cleanSongsterrName(s.Title)
|
|
// Build standard canonical Songsterr tab URL string
|
|
songURL := fmt.Sprintf("https://www.songsterr.com/a/wsa/%s-%s-tab-s%d", cleanArtist, cleanTitle, s.SongID)
|
|
|
|
songs = append(songs, Song{
|
|
Name: s.Title,
|
|
ArtistName: s.Artist,
|
|
URL: songURL,
|
|
ArtistURL: fmt.Sprintf("https://www.songsterr.com/a/wa/artist?id=%d", s.SongID),
|
|
})
|
|
}
|
|
|
|
if len(rawSongs) < size {
|
|
break
|
|
}
|
|
from += size
|
|
}
|
|
|
|
return songs, nil
|
|
}
|
|
|
|
// downloadSongsterrTab handles downloading a tab from Songsterr by executing our
|
|
// TypeScript-based Node scraper utility ('songsterr_dl.ts') as a child subprocess.
|
|
// It parses the child stdout to extract the final compiled Guitar Pro file path.
|
|
func downloadSongsterrTab(tabURL string, outputDir string, delay time.Duration) (string, error) {
|
|
if delay > 0 {
|
|
time.Sleep(delay)
|
|
}
|
|
|
|
scriptPath := "/srv/dev/tab-downloader/songsterr_dl.ts"
|
|
// Execute Node subprocess to resolve and convert the Songsterr tab sheet
|
|
cmd := exec.Command("node", scriptPath, "--url", tabURL, "--output", outputDir)
|
|
outputBytes, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return "", fmt.Errorf("node converter failed: %v\nOutput: %s", err, string(outputBytes))
|
|
}
|
|
|
|
outputStr := string(outputBytes)
|
|
lines := strings.Split(outputStr, "\n")
|
|
for _, line := range lines {
|
|
// Scan stdout lines to find the success report emitted by the TypeScript script
|
|
if strings.Contains(line, "Success! Saved tab file to:") {
|
|
parts := strings.Split(line, "Saved tab file to: ")
|
|
if len(parts) >= 2 {
|
|
return strings.TrimSpace(parts[1]), nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return filepath.Join(outputDir, "songsterr_tab.gp"), nil
|
|
}
|
|
|
|
// downloadSongsterrArtistTabs coordinates batch downloading all available Songsterr tabs for a specific band.
|
|
// It searches Songsterr, applies case-insensitive filtering matching the exact artist name,
|
|
// and sequentially executes the Node downloader with a rate-limiting delay.
|
|
func downloadSongsterrArtistTabs(artistName string, outputDir string, delay time.Duration) error {
|
|
printInfo("Searching Songsterr for artist: %s", artistName)
|
|
songs, err := searchSongsterr(artistName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Filter songs to match the artist name using our robust target-matching algorithm
|
|
var filtered []Song
|
|
for _, s := range songs {
|
|
if isTargetArtist(s.ArtistName, artistName) {
|
|
filtered = append(filtered, s)
|
|
}
|
|
}
|
|
|
|
if len(filtered) == 0 {
|
|
return fmt.Errorf("no tabs found for artist '%s' on Songsterr", artistName)
|
|
}
|
|
|
|
printSuccess("Found %d tabs for this artist on Songsterr.", len(filtered))
|
|
|
|
// Prompt user for selection (defaulting to all)
|
|
selectedSongs, err := promptSelectSongs(filtered)
|
|
if err != nil {
|
|
return fmt.Errorf("selecting songs: %w", err)
|
|
}
|
|
|
|
finalArtistName := artistName
|
|
if len(selectedSongs) > 0 {
|
|
// Try to find the exact collaborator part matching our query to use as the folder name
|
|
targetLower := strings.ToLower(artistName)
|
|
separators := []string{",", " feat. ", " feat ", " ft. ", " ft ", " and ", " & "}
|
|
|
|
parts := []string{selectedSongs[0].ArtistName}
|
|
for _, sep := range separators {
|
|
var nextParts []string
|
|
for _, p := range parts {
|
|
split := strings.Split(p, sep)
|
|
for _, s := range split {
|
|
trimmed := strings.TrimSpace(s)
|
|
if trimmed != "" {
|
|
nextParts = append(nextParts, trimmed)
|
|
}
|
|
}
|
|
}
|
|
parts = nextParts
|
|
}
|
|
|
|
foundPart := false
|
|
for _, part := range parts {
|
|
if strings.ToLower(part) == targetLower {
|
|
finalArtistName = part
|
|
foundPart = true
|
|
break
|
|
}
|
|
}
|
|
if !foundPart {
|
|
// If no exact collaborator part matched, use the first song's full artist name
|
|
finalArtistName = selectedSongs[0].ArtistName
|
|
}
|
|
}
|
|
artistDirName := sanitizeDirName(finalArtistName)
|
|
if artistDirName == "" {
|
|
artistDirName = cleanSongsterrName(artistName)
|
|
}
|
|
artistDir := filepath.Join(outputDir, artistDirName)
|
|
printInfo("Downloading tabs to: %s", artistDir)
|
|
|
|
for i, song := range selectedSongs {
|
|
fmt.Printf("[%d/%d] Downloading: %s\n", i+1, len(selectedSongs), song.Name)
|
|
savedPath, err := downloadSongsterrTab(song.URL, artistDir, delay)
|
|
if err != nil {
|
|
printError("Failed to download %s: %v", song.Name, err)
|
|
continue
|
|
}
|
|
printSuccess("Saved to: %s", savedPath)
|
|
}
|
|
|
|
printSuccess("Finished downloading artist tabs from Songsterr!")
|
|
return nil
|
|
}
|
|
|
|
// resolveSongsterrSongURL determines if the song input is a direct link or slug.
|
|
// Otherwise, it queries Songsterr, resolves search conflicts interactively, or auto-resolves to the first match headlessly.
|
|
func resolveSongsterrSongURL(input string, interactive bool) (string, error) {
|
|
if strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://") {
|
|
return input, nil
|
|
}
|
|
|
|
songs, err := searchSongsterr(input)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if len(songs) == 0 {
|
|
return "", fmt.Errorf("no song found matching '%s' on Songsterr", input)
|
|
}
|
|
|
|
if len(songs) == 1 {
|
|
return songs[0].URL, nil
|
|
}
|
|
|
|
if !interactive {
|
|
printWarning("Multiple songs found. Automatically selecting the first match: %s (by %s)", songs[0].Name, songs[0].ArtistName)
|
|
return songs[0].URL, nil
|
|
}
|
|
|
|
printWarning("Multiple songs found on Songsterr:")
|
|
for i, s := range songs {
|
|
fmt.Printf(" [%d] %s (by %s)\n", i+1, s.Name, s.ArtistName)
|
|
}
|
|
|
|
reader := bufio.NewReader(os.Stdin)
|
|
for {
|
|
fmt.Printf("%sSelect song number (1-%d): %s", colorYellow, len(songs), colorReset)
|
|
choiceStr, _ := reader.ReadString('\n')
|
|
choiceStr = strings.TrimSpace(choiceStr)
|
|
idx, err := strconv.Atoi(choiceStr)
|
|
if err == nil && idx >= 1 && idx <= len(songs) {
|
|
return songs[idx-1].URL, nil
|
|
}
|
|
printError("Invalid choice. Please choose a number between 1 and %d", len(songs))
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Interactive TUI & Main
|
|
// ============================================================================
|
|
|
|
// printSongsTable prints a gorgeous colorized Unicode table displaying a collection of songs.
|
|
// Features a Cyan Bold header row, Green index numbers for selection, and Gray URLs.
|
|
func printSongsTable(songs []Song) {
|
|
if len(songs) == 0 {
|
|
fmt.Println("No songs to display.")
|
|
return
|
|
}
|
|
|
|
maxIdxLen := len(strconv.Itoa(len(songs)))
|
|
if maxIdxLen < 3 {
|
|
maxIdxLen = 3 // "Idx"
|
|
}
|
|
maxNameLen := 10 // "Song Title"
|
|
maxArtistLen := 6 // "Artist"
|
|
maxURLLen := 3 // "URL"
|
|
|
|
// Dynamically calculate column widths to perfectly match longest strings
|
|
for _, s := range songs {
|
|
if len(s.Name) > maxNameLen {
|
|
maxNameLen = len(s.Name)
|
|
}
|
|
if len(s.ArtistName) > maxArtistLen {
|
|
maxArtistLen = len(s.ArtistName)
|
|
}
|
|
if len(s.URL) > maxURLLen {
|
|
maxURLLen = len(s.URL)
|
|
}
|
|
}
|
|
|
|
// Cap widths to prevent wrapping issues on standard sized terminals
|
|
if maxNameLen > 40 {
|
|
maxNameLen = 40
|
|
}
|
|
if maxArtistLen > 30 {
|
|
maxArtistLen = 30
|
|
}
|
|
|
|
truncate := func(s string, maxLen int) string {
|
|
if len(s) > maxLen {
|
|
return s[:maxLen-3] + "..."
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Top border
|
|
fmt.Printf("%s┌─%s─┬─%s─┬─%s─┬─%s─┐%s\n",
|
|
colorGray,
|
|
strings.Repeat("─", maxIdxLen),
|
|
strings.Repeat("─", maxNameLen),
|
|
strings.Repeat("─", maxArtistLen),
|
|
strings.Repeat("─", maxURLLen),
|
|
colorReset)
|
|
|
|
// Header row
|
|
fmt.Printf("%s│%s %s%-*s%s %s│%s %s%-*s%s %s│%s %s%-*s%s %s│%s %s%-*s%s %s│%s\n",
|
|
colorGray, colorReset,
|
|
colorCyan+colorBold, maxIdxLen, "Idx", colorReset,
|
|
colorGray, colorReset,
|
|
colorCyan+colorBold, maxNameLen, "Song Title", colorReset,
|
|
colorGray, colorReset,
|
|
colorCyan+colorBold, maxArtistLen, "Artist", colorReset,
|
|
colorGray, colorReset,
|
|
colorCyan+colorBold, maxURLLen, "URL", colorReset,
|
|
colorGray, colorReset)
|
|
|
|
// Separator border
|
|
fmt.Printf("%s├─%s─┼─%s─┼─%s─┼─%s─┤%s\n",
|
|
colorGray,
|
|
strings.Repeat("─", maxIdxLen),
|
|
strings.Repeat("─", maxNameLen),
|
|
strings.Repeat("─", maxArtistLen),
|
|
strings.Repeat("─", maxURLLen),
|
|
colorReset)
|
|
|
|
// Data rows
|
|
for i, s := range songs {
|
|
fmt.Printf("%s│%s %s%-*s%s %s│%s %s%-*s%s %s│%s %s%-*s%s %s│%s %s%-*s%s %s│%s\n",
|
|
colorGray, colorReset,
|
|
colorGreen, maxIdxLen, strconv.Itoa(i+1), colorReset,
|
|
colorGray, colorReset,
|
|
colorWhite, maxNameLen, truncate(s.Name, maxNameLen), colorReset,
|
|
colorGray, colorReset,
|
|
colorWhite, maxArtistLen, truncate(s.ArtistName, maxArtistLen), colorReset,
|
|
colorGray, colorReset,
|
|
colorGray, maxURLLen, s.URL, colorReset,
|
|
colorGray, colorReset)
|
|
}
|
|
|
|
// Bottom border
|
|
fmt.Printf("%s└─%s─┴─%s─┴─%s─┴─%s─┘%s\n",
|
|
colorGray,
|
|
strings.Repeat("─", maxIdxLen),
|
|
strings.Repeat("─", maxNameLen),
|
|
strings.Repeat("─", maxArtistLen),
|
|
strings.Repeat("─", maxURLLen),
|
|
colorReset)
|
|
}
|
|
|
|
// printArtistsTable prints a colorized Unicode block table for artist/band search listings.
|
|
func printArtistsTable(artists []Artist) {
|
|
if len(artists) == 0 {
|
|
fmt.Println("No artists to display.")
|
|
return
|
|
}
|
|
|
|
maxIdxLen := len(strconv.Itoa(len(artists)))
|
|
if maxIdxLen < 3 {
|
|
maxIdxLen = 3 // "Idx"
|
|
}
|
|
maxNameLen := 11 // "Artist Name"
|
|
maxURLLen := 3 // "URL"
|
|
|
|
for _, a := range artists {
|
|
if len(a.Name) > maxNameLen {
|
|
maxNameLen = len(a.Name)
|
|
}
|
|
if len(a.URL) > maxURLLen {
|
|
maxURLLen = len(a.URL)
|
|
}
|
|
}
|
|
|
|
if maxNameLen > 40 {
|
|
maxNameLen = 40
|
|
}
|
|
|
|
truncate := func(s string, maxLen int) string {
|
|
if len(s) > maxLen {
|
|
return s[:maxLen-3] + "..."
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Top border
|
|
fmt.Printf("%s┌─%s─┬─%s─┬─%s─┐%s\n",
|
|
colorGray,
|
|
strings.Repeat("─", maxIdxLen),
|
|
strings.Repeat("─", maxNameLen),
|
|
strings.Repeat("─", maxURLLen),
|
|
colorReset)
|
|
|
|
// Header row
|
|
fmt.Printf("%s│%s %s%-*s%s %s│%s %s%-*s%s %s│%s %s%-*s%s %s│%s\n",
|
|
colorGray, colorReset,
|
|
colorCyan+colorBold, maxIdxLen, "Idx", colorReset,
|
|
colorGray, colorReset,
|
|
colorCyan+colorBold, maxNameLen, "Artist Name", colorReset,
|
|
colorGray, colorReset,
|
|
colorCyan+colorBold, maxURLLen, "URL", colorReset,
|
|
colorGray, colorReset)
|
|
|
|
// Separator border
|
|
fmt.Printf("%s├─%s─┼─%s─┼─%s─┤%s\n",
|
|
colorGray,
|
|
strings.Repeat("─", maxIdxLen),
|
|
strings.Repeat("─", maxNameLen),
|
|
strings.Repeat("─", maxURLLen),
|
|
colorReset)
|
|
|
|
// Data rows
|
|
for i, a := range artists {
|
|
fmt.Printf("%s│%s %s%-*s%s %s│%s %s%-*s%s %s│%s %s%-*s%s %s│%s\n",
|
|
colorGray, colorReset,
|
|
colorGreen, maxIdxLen, strconv.Itoa(i+1), colorReset,
|
|
colorGray, colorReset,
|
|
colorWhite, maxNameLen, truncate(a.Name, maxNameLen), colorReset,
|
|
colorGray, colorReset,
|
|
colorGray, maxURLLen, a.URL, colorReset,
|
|
colorGray, colorReset)
|
|
}
|
|
|
|
// Bottom border
|
|
fmt.Printf("%s└─%s─┴─%s─┴─%s─┘%s\n",
|
|
colorGray,
|
|
strings.Repeat("─", maxIdxLen),
|
|
strings.Repeat("─", maxNameLen),
|
|
strings.Repeat("─", maxURLLen),
|
|
colorReset)
|
|
}
|
|
|
|
// interactWithArtist launches a secondary submenu prompt for a specific artist.
|
|
// Allows batch downloading all tabs or choosing individual songs interactively.
|
|
func interactWithArtist(artistURL, artistName string, reader *bufio.Reader) {
|
|
printInfo("Fetching tabs list for %s...", artistName)
|
|
songs, err := fetchArtistTabs(artistURL)
|
|
if err != nil {
|
|
printError("Failed to fetch artist tabs: %v", err)
|
|
return
|
|
}
|
|
|
|
if len(songs) == 0 {
|
|
printWarning("No tabs found for %s.", artistName)
|
|
return
|
|
}
|
|
|
|
printSuccess("Found %d tabs for %s.", len(songs), artistName)
|
|
printSongsTable(songs)
|
|
|
|
for {
|
|
fmt.Println("\nOptions:")
|
|
fmt.Println(" 1. Download tabs for this artist (allows selection/all)")
|
|
fmt.Println(" 2. Download a specific tab")
|
|
fmt.Println(" 3. Return to main menu")
|
|
fmt.Printf("%sEnter option (1-3): %s", colorYellow, colorReset)
|
|
|
|
opt, _ := reader.ReadString('\n')
|
|
opt = strings.TrimSpace(opt)
|
|
|
|
switch opt {
|
|
case "1":
|
|
fmt.Printf("%sEnter destination directory (default \"downloads\"): %s", colorYellow, colorReset)
|
|
outDir, _ := reader.ReadString('\n')
|
|
outDir = strings.TrimSpace(outDir)
|
|
if outDir == "" {
|
|
outDir = "downloads"
|
|
}
|
|
|
|
err = downloadArtistTabs(artistURL, outDir, 1000*time.Millisecond)
|
|
if err != nil {
|
|
printError("Download failed: %v", err)
|
|
}
|
|
return
|
|
|
|
case "2":
|
|
fmt.Printf("%sEnter tab number (1-%d): %s", colorYellow, len(songs), colorReset)
|
|
numStr, _ := reader.ReadString('\n')
|
|
numStr = strings.TrimSpace(numStr)
|
|
num, err := strconv.Atoi(numStr)
|
|
if err != nil || num < 1 || num > len(songs) {
|
|
printError("Invalid tab number.")
|
|
continue
|
|
}
|
|
|
|
fmt.Printf("%sEnter destination directory (default \"downloads\"): %s", colorYellow, colorReset)
|
|
outDir, _ := reader.ReadString('\n')
|
|
outDir = strings.TrimSpace(outDir)
|
|
if outDir == "" {
|
|
outDir = "downloads"
|
|
}
|
|
|
|
song := songs[num-1]
|
|
printInfo("Downloading %s...", song.Name)
|
|
savedPath, err := downloadTab(song.URL, outDir, 0)
|
|
if err != nil {
|
|
printError("Download failed: %v", err)
|
|
} else {
|
|
printSuccess("Saved tab to: %s", savedPath)
|
|
}
|
|
|
|
case "3":
|
|
return
|
|
default:
|
|
printError("Invalid option.")
|
|
}
|
|
}
|
|
}
|
|
|
|
// runInteractive starts the interactive console CLI text user interface loop.
|
|
// Provides option menus for dual-source selection, queries, switches and fallbacks.
|
|
func runInteractive(startSite string) {
|
|
reader := bufio.NewReader(os.Stdin)
|
|
currentSite := startSite
|
|
|
|
// Warn the user at TUI startup if Node.js is missing from their PATH (affects Songsterr features)
|
|
if _, err := exec.LookPath("node"); err != nil {
|
|
printWarning("Node.js was not found in PATH. Downloading and converting tabs from Songsterr will be unavailable.")
|
|
}
|
|
|
|
for {
|
|
// Display colorized header depending on which backend (Songsterr / GProTab) is active
|
|
titleText := fmt.Sprintf("Tab Downloader Menu (Site: %s)", strings.Title(currentSite))
|
|
printHeader(titleText)
|
|
fmt.Println("1. Search for Artists")
|
|
fmt.Println("2. Search for Songs")
|
|
fmt.Println("3. Download Tabs for an Artist (allows selection/all)")
|
|
fmt.Println("4. Download a Specific Song Tab")
|
|
fmt.Printf("5. Switch Site Source (Current: %s)\n", strings.Title(currentSite))
|
|
fmt.Println("6. Exit")
|
|
fmt.Println("==================================================")
|
|
fmt.Printf("%sEnter choice (1-6): %s", colorYellow, colorReset)
|
|
|
|
choice, _ := reader.ReadString('\n')
|
|
choice = strings.TrimSpace(choice)
|
|
|
|
switch choice {
|
|
case "1":
|
|
if currentSite == "songsterr" {
|
|
printWarning("Songsterr searches by songs primarily. Conducting a general songsterr search...")
|
|
}
|
|
|
|
fmt.Printf("%sEnter artist name to search: %s", colorYellow, colorReset)
|
|
query, _ := reader.ReadString('\n')
|
|
query = strings.TrimSpace(query)
|
|
if query == "" {
|
|
continue
|
|
}
|
|
|
|
if currentSite == "gprotab" {
|
|
artists, err := searchArtists(query)
|
|
if err != nil {
|
|
printError("Search failed: %v", err)
|
|
continue
|
|
}
|
|
|
|
if len(artists) == 0 {
|
|
printWarning("No artists found matching '%s'", query)
|
|
continue
|
|
}
|
|
|
|
printSuccess("Found %d artists:", len(artists))
|
|
printArtistsTable(artists)
|
|
|
|
fmt.Printf("\n%sSelect artist number to view tabs (or 'q' to return): %s", colorYellow, colorReset)
|
|
subChoice, _ := reader.ReadString('\n')
|
|
subChoice = strings.TrimSpace(subChoice)
|
|
if subChoice == "q" || subChoice == "" {
|
|
continue
|
|
}
|
|
|
|
idx, err := strconv.Atoi(subChoice)
|
|
if err != nil || idx < 1 || idx > len(artists) {
|
|
printError("Invalid artist selection.")
|
|
continue
|
|
}
|
|
|
|
artist := artists[idx-1]
|
|
interactWithArtist(artist.URL, artist.Name, reader)
|
|
} else {
|
|
// Songsterr Search flow
|
|
songs, err := searchSongsterr(query)
|
|
if err != nil || len(songs) == 0 {
|
|
errDetail := "no matches found"
|
|
if err != nil {
|
|
errDetail = err.Error()
|
|
}
|
|
printWarning("Songsterr search failed or empty: %s. Falling back to GProTab.net...", errDetail)
|
|
|
|
artists, fallbackErr := searchArtists(query)
|
|
if fallbackErr != nil || len(artists) == 0 {
|
|
printError("Fallback search on GProTab.net also failed or was empty.")
|
|
continue
|
|
}
|
|
|
|
printSuccess("Found %d artists on GProTab.net:", len(artists))
|
|
printArtistsTable(artists)
|
|
|
|
fmt.Printf("\n%sSelect artist number to view tabs (or 'q' to return): %s", colorYellow, colorReset)
|
|
subChoice, _ := reader.ReadString('\n')
|
|
subChoice = strings.TrimSpace(subChoice)
|
|
if subChoice == "q" || subChoice == "" {
|
|
continue
|
|
}
|
|
|
|
idx, err := strconv.Atoi(subChoice)
|
|
if err != nil || idx < 1 || idx > len(artists) {
|
|
printError("Invalid artist selection.")
|
|
continue
|
|
}
|
|
|
|
artist := artists[idx-1]
|
|
interactWithArtist(artist.URL, artist.Name, reader)
|
|
} else {
|
|
printSuccess("Found %d matches on Songsterr:", len(songs))
|
|
printSongsTable(songs)
|
|
|
|
fmt.Printf("\n%sSelect song number to download (or 'q' to return): %s", colorYellow, colorReset)
|
|
subChoice, _ := reader.ReadString('\n')
|
|
subChoice = strings.TrimSpace(subChoice)
|
|
if subChoice == "q" || subChoice == "" {
|
|
continue
|
|
}
|
|
|
|
idx, err := strconv.Atoi(subChoice)
|
|
if err != nil || idx < 1 || idx > len(songs) {
|
|
printError("Invalid song selection.")
|
|
continue
|
|
}
|
|
|
|
song := songs[idx-1]
|
|
fmt.Printf("%sEnter destination directory (default \"downloads\"): %s", colorYellow, colorReset)
|
|
outDir, _ := reader.ReadString('\n')
|
|
outDir = strings.TrimSpace(outDir)
|
|
if outDir == "" {
|
|
outDir = "downloads"
|
|
}
|
|
|
|
printInfo("Downloading %s from Songsterr...", song.Name)
|
|
savedPath, err := downloadSongsterrTab(song.URL, outDir, 0)
|
|
if err != nil {
|
|
printWarning("Songsterr download failed: %v. Falling back to GProTab.net...", err)
|
|
fallbackURL, fallbackErr := resolveSongURL(song.Name, true)
|
|
if fallbackErr == nil {
|
|
savedPath, err = downloadTab(fallbackURL, outDir, 0)
|
|
} else {
|
|
err = fallbackErr
|
|
}
|
|
}
|
|
if err != nil {
|
|
printError("Download failed: %v", err)
|
|
} else {
|
|
printSuccess("Saved tab to: %s", savedPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
case "2":
|
|
fmt.Printf("%sEnter song name to search: %s", colorYellow, colorReset)
|
|
query, _ := reader.ReadString('\n')
|
|
query = strings.TrimSpace(query)
|
|
if query == "" {
|
|
continue
|
|
}
|
|
|
|
if currentSite == "gprotab" {
|
|
songs, err := searchSongs(query)
|
|
if err != nil {
|
|
printError("Search failed: %v", err)
|
|
continue
|
|
}
|
|
|
|
if len(songs) == 0 {
|
|
printWarning("No songs found matching '%s'", query)
|
|
continue
|
|
}
|
|
|
|
printSuccess("Found %d songs:", len(songs))
|
|
printSongsTable(songs)
|
|
|
|
fmt.Printf("\n%sSelect song number to download (or 'q' to return): %s", colorYellow, colorReset)
|
|
subChoice, _ := reader.ReadString('\n')
|
|
subChoice = strings.TrimSpace(subChoice)
|
|
if subChoice == "q" || subChoice == "" {
|
|
continue
|
|
}
|
|
|
|
idx, err := strconv.Atoi(subChoice)
|
|
if err != nil || idx < 1 || idx > len(songs) {
|
|
printError("Invalid song selection.")
|
|
continue
|
|
}
|
|
|
|
song := songs[idx-1]
|
|
fmt.Printf("%sEnter destination directory (default \"downloads\"): %s", colorYellow, colorReset)
|
|
outDir, _ := reader.ReadString('\n')
|
|
outDir = strings.TrimSpace(outDir)
|
|
if outDir == "" {
|
|
outDir = "downloads"
|
|
}
|
|
|
|
printInfo("Downloading %s...", song.Name)
|
|
savedPath, err := downloadTab(song.URL, outDir, 0)
|
|
if err != nil {
|
|
printError("Download failed: %v", err)
|
|
} else {
|
|
printSuccess("Saved tab to: %s", savedPath)
|
|
}
|
|
} else {
|
|
// Songsterr Search
|
|
songs, err := searchSongsterr(query)
|
|
if err != nil || len(songs) == 0 {
|
|
errDetail := "no matches found"
|
|
if err != nil {
|
|
errDetail = err.Error()
|
|
}
|
|
printWarning("Songsterr search failed or empty: %s. Falling back to GProTab.net...", errDetail)
|
|
|
|
songs, fallbackErr := searchSongs(query)
|
|
if fallbackErr != nil || len(songs) == 0 {
|
|
printError("Fallback search on GProTab.net also failed or was empty.")
|
|
continue
|
|
}
|
|
|
|
printSuccess("Found %d songs on GProTab.net:", len(songs))
|
|
printSongsTable(songs)
|
|
|
|
fmt.Printf("\n%sSelect song number to download (or 'q' to return): %s", colorYellow, colorReset)
|
|
subChoice, _ := reader.ReadString('\n')
|
|
subChoice = strings.TrimSpace(subChoice)
|
|
if subChoice == "q" || subChoice == "" {
|
|
continue
|
|
}
|
|
|
|
idx, err := strconv.Atoi(subChoice)
|
|
if err != nil || idx < 1 || idx > len(songs) {
|
|
printError("Invalid song selection.")
|
|
continue
|
|
}
|
|
|
|
song := songs[idx-1]
|
|
fmt.Printf("%sEnter destination directory (default \"downloads\"): %s", colorYellow, colorReset)
|
|
outDir, _ := reader.ReadString('\n')
|
|
outDir = strings.TrimSpace(outDir)
|
|
if outDir == "" {
|
|
outDir = "downloads"
|
|
}
|
|
|
|
printInfo("Downloading %s...", song.Name)
|
|
savedPath, err := downloadTab(song.URL, outDir, 0)
|
|
if err != nil {
|
|
printError("Download failed: %v", err)
|
|
} else {
|
|
printSuccess("Saved tab to: %s", savedPath)
|
|
}
|
|
} else {
|
|
printSuccess("Found %d songs on Songsterr:", len(songs))
|
|
printSongsTable(songs)
|
|
|
|
fmt.Printf("\n%sSelect song number to download (or 'q' to return): %s", colorYellow, colorReset)
|
|
subChoice, _ := reader.ReadString('\n')
|
|
subChoice = strings.TrimSpace(subChoice)
|
|
if subChoice == "q" || subChoice == "" {
|
|
continue
|
|
}
|
|
|
|
idx, err := strconv.Atoi(subChoice)
|
|
if err != nil || idx < 1 || idx > len(songs) {
|
|
printError("Invalid song selection.")
|
|
continue
|
|
}
|
|
|
|
song := songs[idx-1]
|
|
fmt.Printf("%sEnter destination directory (default \"downloads\"): %s", colorYellow, colorReset)
|
|
outDir, _ := reader.ReadString('\n')
|
|
outDir = strings.TrimSpace(outDir)
|
|
if outDir == "" {
|
|
outDir = "downloads"
|
|
}
|
|
|
|
printInfo("Downloading %s from Songsterr...", song.Name)
|
|
savedPath, err := downloadSongsterrTab(song.URL, outDir, 0)
|
|
if err != nil {
|
|
printWarning("Songsterr download failed: %v. Falling back to GProTab.net...", err)
|
|
fallbackURL, fallbackErr := resolveSongURL(song.Name, true)
|
|
if fallbackErr == nil {
|
|
savedPath, err = downloadTab(fallbackURL, outDir, 0)
|
|
} else {
|
|
err = fallbackErr
|
|
}
|
|
}
|
|
if err != nil {
|
|
printError("Download failed: %v", err)
|
|
} else {
|
|
printSuccess("Saved tab to: %s", savedPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
case "3":
|
|
fmt.Printf("%sEnter artist name: %s", colorYellow, colorReset)
|
|
input, _ := reader.ReadString('\n')
|
|
input = strings.TrimSpace(input)
|
|
if input == "" {
|
|
continue
|
|
}
|
|
|
|
fmt.Printf("%sEnter destination directory (default \"downloads\"): %s", colorYellow, colorReset)
|
|
outDir, _ := reader.ReadString('\n')
|
|
outDir = strings.TrimSpace(outDir)
|
|
if outDir == "" {
|
|
outDir = "downloads"
|
|
}
|
|
|
|
if currentSite == "gprotab" {
|
|
artistURL, err := resolveArtistURL(input, true)
|
|
if err != nil {
|
|
printError("%v", err)
|
|
continue
|
|
}
|
|
printInfo("Starting batch download for artist from GProTab...")
|
|
err = downloadArtistTabs(artistURL, outDir, 1000*time.Millisecond)
|
|
if err != nil {
|
|
printError("Batch download failed: %v", err)
|
|
}
|
|
} else {
|
|
printInfo("Starting batch download for artist from Songsterr...")
|
|
err := downloadSongsterrArtistTabs(input, outDir, 1000*time.Millisecond)
|
|
if err != nil {
|
|
printWarning("Songsterr batch download failed: %v. Falling back to GProTab...", err)
|
|
artistURL, fallbackErr := resolveArtistURL(input, true)
|
|
if fallbackErr != nil {
|
|
printError("GProTab fallback failed: %v", fallbackErr)
|
|
continue
|
|
}
|
|
printInfo("Starting batch download for artist from GProTab...")
|
|
err = downloadArtistTabs(artistURL, outDir, 1000*time.Millisecond)
|
|
if err != nil {
|
|
printError("Fallback GProTab download failed: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
case "4":
|
|
fmt.Printf("%sEnter song name, URL or slug: %s", colorYellow, colorReset)
|
|
input, _ := reader.ReadString('\n')
|
|
input = strings.TrimSpace(input)
|
|
if input == "" {
|
|
continue
|
|
}
|
|
|
|
fmt.Printf("%sEnter destination directory (default \"downloads\"): %s", colorYellow, colorReset)
|
|
outDir, _ := reader.ReadString('\n')
|
|
outDir = strings.TrimSpace(outDir)
|
|
if outDir == "" {
|
|
outDir = "downloads"
|
|
}
|
|
|
|
if currentSite == "gprotab" {
|
|
songURL, err := resolveSongURL(input, true)
|
|
if err != nil {
|
|
printError("%v", err)
|
|
continue
|
|
}
|
|
printInfo("Downloading song tab...")
|
|
savedPath, err := downloadTab(songURL, outDir, 0)
|
|
if err != nil {
|
|
printError("Download failed: %v", err)
|
|
} else {
|
|
printSuccess("Saved tab to: %s", savedPath)
|
|
}
|
|
} else {
|
|
songURL, err := resolveSongsterrSongURL(input, true)
|
|
var downloadErr error
|
|
var savedPath string
|
|
if err == nil {
|
|
printInfo("Downloading song tab from Songsterr...")
|
|
savedPath, downloadErr = downloadSongsterrTab(songURL, outDir, 0)
|
|
}
|
|
if err != nil || downloadErr != nil {
|
|
errDetail := err
|
|
if downloadErr != nil {
|
|
errDetail = downloadErr
|
|
}
|
|
printWarning("Songsterr download failed: %v. Falling back to GProTab...", errDetail)
|
|
songURL, fallbackErr := resolveSongURL(input, true)
|
|
if fallbackErr != nil {
|
|
printError("GProTab fallback failed: %v", fallbackErr)
|
|
continue
|
|
}
|
|
printInfo("Downloading song tab from GProTab...")
|
|
savedPath, downloadErr = downloadTab(songURL, outDir, 0)
|
|
if downloadErr != nil {
|
|
printError("Fallback GProTab download failed: %v", downloadErr)
|
|
} else {
|
|
printSuccess("Saved tab to: %s", savedPath)
|
|
}
|
|
} else {
|
|
printSuccess("Saved tab to: %s", savedPath)
|
|
}
|
|
}
|
|
|
|
case "5":
|
|
if currentSite == "gprotab" {
|
|
currentSite = "songsterr"
|
|
} else {
|
|
currentSite = "gprotab"
|
|
}
|
|
printSuccess("Successfully switched source database to: %s", strings.Title(currentSite))
|
|
|
|
case "6":
|
|
printInfo("Goodbye!")
|
|
os.Exit(0)
|
|
|
|
default:
|
|
printError("Invalid choice. Please select 1-6.")
|
|
}
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
searchArtistFlag := flag.String("search-artist", "", "Search for artists matching the query")
|
|
searchSongFlag := flag.String("search-song", "", "Search for songs matching the query")
|
|
downloadArtistFlag := flag.String("download-artist", "", "Download all tabs for the specified artist (slug or URL or search query)")
|
|
downloadSongFlag := flag.String("download-song", "", "Download the specified song tab (slug or URL or search query)")
|
|
outputFlag := flag.String("output", "downloads", "Destination directory for downloads")
|
|
delayFlag := flag.Int("delay", 1000, "Delay in milliseconds between downloads (polite mode)")
|
|
siteFlag := flag.String("site", "songsterr", "Select source website: 'songsterr' or 'gprotab'")
|
|
|
|
flag.Parse()
|
|
|
|
delay := time.Duration(*delayFlag) * time.Millisecond
|
|
site := strings.ToLower(strings.TrimSpace(*siteFlag))
|
|
|
|
// Ensure Node.js is available in PATH when headlessly scraping/downloading from Songsterr
|
|
if *downloadSongFlag != "" || *downloadArtistFlag != "" || site == "songsterr" {
|
|
if _, err := exec.LookPath("node"); err != nil {
|
|
printError("Node.js is not installed or not found in PATH. It is required to download and convert Songsterr tabs.")
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
if *searchArtistFlag != "" {
|
|
if site == "songsterr" {
|
|
printInfo("On Songsterr, searches are primary by song title/metadata. Conducting general search...")
|
|
songs, err := searchSongsterr(*searchArtistFlag)
|
|
if err != nil || len(songs) == 0 {
|
|
errDetail := "no songs found"
|
|
if err != nil {
|
|
errDetail = err.Error()
|
|
}
|
|
printWarning("Songsterr artist search failed or empty: %s", errDetail)
|
|
printWarning("Falling back to GProTab.net...")
|
|
artists, fallbackErr := searchArtists(*searchArtistFlag)
|
|
if fallbackErr != nil {
|
|
printError("GProTab fallback search failed: %v", fallbackErr)
|
|
os.Exit(1)
|
|
}
|
|
printSuccess("Found %d artists on GProTab.net:", len(artists))
|
|
printArtistsTable(artists)
|
|
} else {
|
|
// Filter search results to show matching tracks for the target artist
|
|
var filtered []Song
|
|
for _, s := range songs {
|
|
if isTargetArtist(s.ArtistName, *searchArtistFlag) {
|
|
filtered = append(filtered, s)
|
|
}
|
|
}
|
|
|
|
if len(filtered) == 0 {
|
|
printWarning("No exact artist matches found on Songsterr. Displaying all raw search results:")
|
|
filtered = songs
|
|
} else {
|
|
printSuccess("Found %d matching songs for artist '%s' on Songsterr:", len(filtered), *searchArtistFlag)
|
|
}
|
|
printSongsTable(filtered)
|
|
}
|
|
} else {
|
|
artists, err := searchArtists(*searchArtistFlag)
|
|
if err != nil {
|
|
printError("Search failed: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
printSuccess("Found %d artists:", len(artists))
|
|
printArtistsTable(artists)
|
|
}
|
|
os.Exit(0)
|
|
}
|
|
|
|
if *searchSongFlag != "" {
|
|
if site == "songsterr" {
|
|
songs, err := searchSongsterr(*searchSongFlag)
|
|
if err != nil || len(songs) == 0 {
|
|
errDetail := "no songs found"
|
|
if err != nil {
|
|
errDetail = err.Error()
|
|
}
|
|
printWarning("Songsterr song search failed or empty: %s", errDetail)
|
|
printWarning("Falling back to GProTab.net...")
|
|
songs, fallbackErr := searchSongs(*searchSongFlag)
|
|
if fallbackErr != nil {
|
|
printError("GProTab fallback search failed: %v", fallbackErr)
|
|
os.Exit(1)
|
|
}
|
|
printSuccess("Found %d songs on GProTab.net:", len(songs))
|
|
printSongsTable(songs)
|
|
} else {
|
|
printSuccess("Found %d songs on Songsterr:", len(songs))
|
|
printSongsTable(songs)
|
|
}
|
|
} else {
|
|
songs, err := searchSongs(*searchSongFlag)
|
|
if err != nil {
|
|
printError("Search failed: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
printSuccess("Found %d songs:", len(songs))
|
|
printSongsTable(songs)
|
|
}
|
|
os.Exit(0)
|
|
}
|
|
|
|
if *downloadArtistFlag != "" {
|
|
var err error
|
|
if site == "songsterr" {
|
|
printInfo("Trying Songsterr first for artist: %s", *downloadArtistFlag)
|
|
err = downloadSongsterrArtistTabs(*downloadArtistFlag, *outputFlag, delay)
|
|
if err != nil {
|
|
printWarning("Songsterr artist batch download failed: %v", err)
|
|
printWarning("Falling back to GProTab.net...")
|
|
var artistURL string
|
|
artistURL, err = resolveArtistURL(*downloadArtistFlag, false)
|
|
if err == nil {
|
|
err = downloadArtistTabs(artistURL, *outputFlag, delay)
|
|
}
|
|
}
|
|
} else {
|
|
var artistURL string
|
|
artistURL, err = resolveArtistURL(*downloadArtistFlag, false)
|
|
if err == nil {
|
|
err = downloadArtistTabs(artistURL, *outputFlag, delay)
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
printError("Batch download failed: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
os.Exit(0)
|
|
}
|
|
|
|
if *downloadSongFlag != "" {
|
|
var savedPath string
|
|
var err error
|
|
if site == "songsterr" {
|
|
printInfo("Trying Songsterr first for song: %s", *downloadSongFlag)
|
|
var songURL string
|
|
songURL, err = resolveSongsterrSongURL(*downloadSongFlag, false)
|
|
if err == nil {
|
|
savedPath, err = downloadSongsterrTab(songURL, *outputFlag, 0)
|
|
}
|
|
if err != nil {
|
|
printWarning("Songsterr search/download failed: %v", err)
|
|
printWarning("Falling back to GProTab.net...")
|
|
var fallbackSongURL string
|
|
fallbackSongURL, err = resolveSongURL(*downloadSongFlag, false)
|
|
if err == nil {
|
|
savedPath, err = downloadTab(fallbackSongURL, *outputFlag, 0)
|
|
}
|
|
}
|
|
} else {
|
|
var songURL string
|
|
songURL, err = resolveSongURL(*downloadSongFlag, false)
|
|
if err == nil {
|
|
savedPath, err = downloadTab(songURL, *outputFlag, 0)
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
printError("Download failed: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
printSuccess("Saved tab to: %s", savedPath)
|
|
os.Exit(0)
|
|
}
|
|
|
|
runInteractive(site)
|
|
}
|