// 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(`
    ([\s\S]*?)
`) 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(`([^<]+)`) 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(`
[\s\S]*?([^<]+)[\s\S]*?([^<]+)`) 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(`
  • ([^<]+)
  • `) 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) }