Files
tab-downloader/main.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)
}