From d56d4714dbb1af080bbabc7ab580ce6d744fd4d2 Mon Sep 17 00:00:00 2001 From: wompmacho Date: Sun, 24 May 2026 04:47:53 +0000 Subject: [PATCH] add initial version of tab-downloader --- .gitignore | 16 + README.md | 147 ++ go.mod | 3 + main.go | 1724 ++++++++++++++++++ main_test.go | 300 +++ package-lock.json | 31 + package.json | 7 + songsterr/duration-mapper.test.ts | 44 + songsterr/duration-mapper.ts | 91 + songsterr/instrument-map.ts | 41 + songsterr/songsterr-to-alphatab.converter.ts | 880 +++++++++ songsterr/types.ts | 163 ++ songsterr_dl.ts | 181 ++ 13 files changed, 3628 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 go.mod create mode 100644 main.go create mode 100644 main_test.go create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 songsterr/duration-mapper.test.ts create mode 100644 songsterr/duration-mapper.ts create mode 100644 songsterr/instrument-map.ts create mode 100644 songsterr/songsterr-to-alphatab.converter.ts create mode 100644 songsterr/types.ts create mode 100644 songsterr_dl.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7deba3c --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Dependencies +node_modules/ + +# Compiled Go binary +tab-downloader + +# Default downloads folders +downloads/ + +# OS specific files +.DS_Store +Thumbs.db +.antigravitycli/ + +# Log files +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..b0aea03 --- /dev/null +++ b/README.md @@ -0,0 +1,147 @@ +# Guitar Tab Downloader + +A simple, fast, and powerful CLI tool to search, view, and download guitar tabs (Guitar Pro formats like `.gp`, `.gp3`, `.gp4`, `.gp5`, `.gpx`, etc.) from both **Songsterr.com** and **GProTab.net**. + +--- + +## Features + +- **Dual-Source Support**: Integrates with both **Songsterr.com** (default) and **GProTab.net**. +- **Songsterr Pagination & Unlimited Downloads**: The 50-tab limit on Songsterr is fully lifted! The tool implements automated offset-pagination loops to retrieve and download *all* available tabs for any artist. +- **Interactive Multi-Tab Selection**: When batch-downloading an artist's tabs, a powerful index range selection engine prompts you (e.g. `1,3,5-10,12`), allowing you to download only specific tabs. Pressing **Enter** downloads all tabs as the default behavior. +- **Automated Fallbacks**: When using Songsterr, if a search or download fails, the tool automatically falls back to GProTab.net to find your tabs. +- **Pretty Table Formatting**: Displays search results in gorgeous, modern Unicode box-drawing tables with ANSI colors: + - Header row styled in **Bold Cyan**. + - Row indexes highlighted in **Bright Green** for quick interactive choices. + - Song/Artist titles in **Bright White** and URLs in muted **Gray**. +- **Interactive TUI Mode**: Launch the tool without flags for an interactive, colorized menu to search, browse, and download tabs on-the-fly. +- **Automated CLI Mode**: Run headlessly with full command-line arguments and flags to automate downloads and search. +- **Polite Batch Downloading**: Includes a configurable delay (default 1 second) between requests to respect rate limits. +- **Smart Filename Extraction**: Saves files using authentic filenames pulled directly from download headers. + +--- + +## Installation & Compilation + +Ensure you have [Go](https://go.dev/) (version 1.20 or higher) and [Node.js](https://nodejs.org/) installed (Node.js is used by the Songsterr download helper script). + +1. Navigate to the project directory: + ```bash + cd /srv/dev/tab-downloader + ``` + +2. Build the binary: + ```bash + go build -o tab-downloader main.go + ``` + +--- + +## Running Tests + +The project includes lightweight, zero-dependency unit tests for both the Go orchestrator and the TypeScript converter utilities. + +### 1. Go Unit Tests + +Run the Go test suite to verify GProTab path normalization, Songsterr name slugifying, and our multi-index selection range parser: + +```bash +go test -v +``` + +### 2. TypeScript Unit Tests + +Run the Node.js native test suite to verify Songsterr musical duration matching and General MIDI program mappings: + +```bash +node --experimental-strip-types songsterr/duration-mapper.test.ts +``` + +--- + +## Usage + +### 1. Interactive TUI Mode + +Run the compiled binary with no arguments to launch the colorized interactive menu: + +```bash +./tab-downloader +``` + +Example prompt: + +```text +=== Guitar Tab Downloader Menu (Site: Songsterr) === +1. Search for Artists +2. Search for Songs +3. Download Tabs for an Artist (allows selection/all) +4. Download a Specific Song Tab +5. Switch Site Source (Current: Songsterr) +6. Exit +================================================== +Enter choice (1-6): +``` + +--- + +### 2. Automated CLI Mode (Flags) + +For automated pipelines or direct searches, run the command with flags. + +#### Search for Artists + +# Search using Songsterr (default) +```bash +./tab-downloader -search-artist "The Eagles" +``` + +# Search on GProTab.net specifically +```bash +./tab-downloader -search-artist "The Eagles" -site gprotab +``` + +#### Search for Songs + +```bash +./tab-downloader -search-song "Hotel California" +``` + +#### Download a Specific Song + +# Downloads the song (automatically falling back to GProTab.net if Songsterr fails) +```bash +./tab-downloader -download-song "Hotel California" -output ./my_tabs +``` + +#### Download All Tabs for an Artist + +Downloads tabs for an artist into a subdirectory named after the artist, with an interactive prompt allowing you to filter specific selections or download all by default: + +```bash +./tab-downloader -download-artist "The Eagles" -output ./my_tabs +``` + +Terminal Prompt Example: + +```text +Enter selection (e.g., 1-5,7,10-12) or press Enter to download all [Default: all]: 1,3,5-7,10 +``` + +--- + +## Technical Details & CLI Flags + +The complete list of supported command-line flags: + +| Flag | Type | Default | Description | +| ------------------ | -------- | ----------- | --------------------------------------------------------------------------------------------------------------- | +| `-site` | `string` | `songsterr` | Select source website: `'songsterr'` or `'gprotab'`. | +| `-search-artist` | `string` | `""` | Search for artists matching the query and print a beautiful Unicode table. | +| `-search-song` | `string` | `""` | Search for songs matching the query and print a beautiful Unicode table. | +| `-download-artist` | `string` | `""` | Download tabs for an artist (prompts with interactive selection). Accepts artist name query, slug, or full URL. | +| `-download-song` | `string` | `""` | Download a single song tab. Accepts song name query, slug, or full URL. | +| `-output` | `string` | `downloads` | Destination directory for downloaded files. | +| `-delay` | `int` | `1000` | Delay in milliseconds between downloads in batch/artist mode. | + +--- diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6c99f1d --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module tab-downloader + +go 1.22.2 diff --git a/main.go b/main.go new file mode 100644 index 0000000..7b50359 --- /dev/null +++ b/main.go @@ -0,0 +1,1724 @@ +// 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) +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..2144a23 --- /dev/null +++ b/main_test.go @@ -0,0 +1,300 @@ +package main + +import ( + "testing" +) + +// TestNormalizePath validates the GProTab path normalization logic +func TestNormalizePath(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "Full https GProTab URL", + input: "https://gprotab.net/en/tabs/metallica/one", + expected: "/en/tabs/metallica/one", + }, + { + name: "Full http GProTab URL", + input: "http://gprotab.net/en/tabs/slayer/raining-blood", + expected: "/en/tabs/slayer/raining-blood", + }, + { + name: "Clean relative path", + input: "/en/tabs/pantera/walk", + expected: "/en/tabs/pantera/walk", + }, + { + name: "Partial slash slug", + input: "megadeth/holy-wars", + expected: "/en/tabs/megadeth/holy-wars", + }, + { + name: "Simple text input", + input: "metallica", + expected: "metallica", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := normalizePath(tt.input) + if result != tt.expected { + t.Errorf("normalizePath(%q) = %q; expected %q", tt.input, result, tt.expected) + } + }) + } +} + +// TestCleanSongsterrName validates slug generation formatting +func TestCleanSongsterrName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "Simple lowercase conversion", + input: "Metallica", + expected: "metallica", + }, + { + name: "Spaces to hyphens", + input: "The Eagles", + expected: "the-eagles", + }, + { + name: "Special characters removal", + input: "Guns N' Roses & Friends!", + expected: "guns-n-roses-friends", + }, + { + name: "Collapsing multiple hyphens", + input: "rock---and---roll", + expected: "rock-and-roll", + }, + { + name: "Trailing and leading hyphens removal", + input: "---led zeppelin---", + expected: "led-zeppelin", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := cleanSongsterrName(tt.input) + if result != tt.expected { + t.Errorf("cleanSongsterrName(%q) = %q; expected %q", tt.input, result, tt.expected) + } + }) + } +} + +// TestSanitizeDirName validates OS-safe folder name formatting +func TestSanitizeDirName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "Preserve capitalization and spaces", + input: "As I Lay Dying", + expected: "As I Lay Dying", + }, + { + name: "Filter standard Windows slash characters", + input: "AC/DC", + expected: "ACDC", + }, + { + name: "Remove illegal OS characters but keep capitalization and symbols", + input: "Guns N' Roses: Live & Loud *Special?", + expected: "Guns N' Roses Live & Loud Special", + }, + { + name: "Collapse adjacent spaces", + input: " Slayer Metal ", + expected: "Slayer Metal", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := sanitizeDirName(tt.input) + if result != tt.expected { + t.Errorf("sanitizeDirName(%q) = %q; expected %q", tt.input, result, tt.expected) + } + }) + } +} + +// TestIsTargetArtist validates the robust sub-artist matching logic +func TestIsTargetArtist(t *testing.T) { + tests := []struct { + name string + songArtist string + targetArtist string + expected bool + }{ + { + name: "Direct matching exact", + songArtist: "August Burns Red", + targetArtist: "August Burns Red", + expected: true, + }, + { + name: "Direct matching case insensitive", + songArtist: "august burns red", + targetArtist: "August Burns Red", + expected: true, + }, + { + name: "Squished name matching", + songArtist: "augustburnsred", + targetArtist: "August Burns Red", + expected: true, + }, + { + name: "Trailing punctuation/comma matching", + songArtist: "August Burns Red,", + targetArtist: "August Burns Red", + expected: true, + }, + { + name: "Collaboration split by comma matching", + songArtist: "August Burns Red, Jeremy McKinnon", + targetArtist: "August Burns Red", + expected: true, + }, + { + name: "Collaboration split by ft matching", + songArtist: "August Burns Red ft. Jeremy McKinnon", + targetArtist: "August Burns Red", + expected: true, + }, + { + name: "Collaborator second in split", + songArtist: "Jeremy McKinnon & August Burns Red", + targetArtist: "August Burns Red", + expected: true, + }, + { + name: "Not matching similar name parody", + songArtist: "August Burns All The Tabs", + targetArtist: "August Burns Red", + expected: false, + }, + { + name: "Not matching misspelling", + songArtist: "August Burn Red", + targetArtist: "August Burns Red", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isTargetArtist(tt.songArtist, tt.targetArtist) + if result != tt.expected { + t.Errorf("isTargetArtist(%q, %q) = %v; expected %v", tt.songArtist, tt.targetArtist, result, tt.expected) + } + }) + } +} + +// TestParseSelection validates parsing selections +func TestParseSelection(t *testing.T) { + tests := []struct { + name string + input string + maxVal int + expected []int + expectError bool + }{ + { + name: "Single number", + input: "3", + maxVal: 10, + expected: []int{2}, + expectError: false, + }, + { + name: "Comma separated", + input: "1,3,5", + maxVal: 10, + expected: []int{0, 2, 4}, + expectError: false, + }, + { + name: "Range", + input: "2-5", + maxVal: 10, + expected: []int{1, 2, 3, 4}, + expectError: false, + }, + { + name: "Mixed comma and range", + input: "1,3-5,8", + maxVal: 10, + expected: []int{0, 2, 3, 4, 7}, + expectError: false, + }, + { + name: "De-duplicate and sort", + input: "5,1-3,2", + maxVal: 10, + expected: []int{0, 1, 2, 4}, + expectError: false, + }, + { + name: "Out of bounds single", + input: "11", + maxVal: 10, + expected: nil, + expectError: true, + }, + { + name: "Out of bounds range", + input: "8-12", + maxVal: 10, + expected: nil, + expectError: true, + }, + { + name: "Empty input", + input: "", + maxVal: 10, + expected: nil, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := parseSelection(tt.input, tt.maxVal) + if tt.expectError { + if err == nil { + t.Errorf("expected error for input %q, but got none", tt.input) + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if len(res) != len(tt.expected) { + t.Errorf("expected length %d, got %d", len(tt.expected), len(res)) + } else { + for i := range res { + if res[i] != tt.expected[i] { + t.Errorf("at index %d: expected %d, got %d", i, tt.expected[i], res[i]) + } + } + } + } + }) + } +} + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b2359d6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,31 @@ +{ + "name": "tab-downloader", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@coderline/alphatab": "^1.8.2", + "@xmldom/xmldom": "^0.9.10" + } + }, + "node_modules/@coderline/alphatab": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/@coderline/alphatab/-/alphatab-1.8.2.tgz", + "integrity": "sha512-Ab11m2IwCdOG+Wbzbv+v4pvJgJnNqw+UjIgKyaTu9TBTWfE7GlOuuElpswkQ2mLB6E4HcOX1FSBopMZdP41B5g==", + "license": "MPL-2.0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "license": "MIT", + "engines": { + "node": ">=14.6" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c1f33a3 --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "type": "module", + "dependencies": { + "@coderline/alphatab": "^1.8.2", + "@xmldom/xmldom": "^0.9.10" + } +} diff --git a/songsterr/duration-mapper.test.ts b/songsterr/duration-mapper.test.ts new file mode 100644 index 0000000..441de2a --- /dev/null +++ b/songsterr/duration-mapper.test.ts @@ -0,0 +1,44 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert'; +import * as alphaTab from '@coderline/alphatab'; +import { mapSongsterrDuration } from './duration-mapper.ts'; +import { mapSongsterrInstrumentToPlayback } from './instrument-map.ts'; + +test('mapSongsterrDuration should map simple and complex durations', () => { + // Test a simple quarter note (1/4) + const quarter = mapSongsterrDuration([1, 4]); + assert.strictEqual(quarter.duration, alphaTab.model.Duration.Quarter); + assert.strictEqual(quarter.dots, 0); + assert.strictEqual(quarter.isApproximate, false); + + // Test a dotted quarter note (3/8) + const dottedQuarter = mapSongsterrDuration([3, 8]); + assert.strictEqual(dottedQuarter.duration, alphaTab.model.Duration.Quarter); + assert.strictEqual(dottedQuarter.dots, 1); + assert.strictEqual(dottedQuarter.isApproximate, false); + + // Test an eighth note (1/8) + const eighth = mapSongsterrDuration([1, 8]); + assert.strictEqual(eighth.duration, alphaTab.model.Duration.Eighth); + assert.strictEqual(eighth.dots, 0); + + // Test a double-dotted quarter note (7/16) + // 7/16 = 0.4375 which is a Quarter Note (0.25) + Half-Quarter (0.125) + Quarter-Quarter (0.0625) + const doubleDottedQuarter = mapSongsterrDuration([7, 16]); + assert.strictEqual(doubleDottedQuarter.duration, alphaTab.model.Duration.Quarter); + assert.strictEqual(doubleDottedQuarter.dots, 2); + assert.strictEqual(doubleDottedQuarter.isApproximate, false); +}); + +test('mapSongsterrInstrumentToPlayback should resolve instruments', () => { + // Test standard Songsterr drum identifier + const drums = mapSongsterrInstrumentToPlayback(1024); + assert.strictEqual(drums.isPercussion, true); + assert.strictEqual(drums.program, 0); + assert.strictEqual(drums.primaryChannel, 9); + + // Test standard Acoustic Guitar (Program 24) + const guitar = mapSongsterrInstrumentToPlayback(24); + assert.strictEqual(guitar.isPercussion, false); + assert.strictEqual(guitar.program, 24); +}); diff --git a/songsterr/duration-mapper.ts b/songsterr/duration-mapper.ts new file mode 100644 index 0000000..fd6e5db --- /dev/null +++ b/songsterr/duration-mapper.ts @@ -0,0 +1,91 @@ +import * as alphaTab from '@coderline/alphatab'; + +/** + * Result structure representing a successfully mapped Songsterr duration in alphaTab terms. + */ +export interface DurationMappingResult { + duration: alphaTab.model.Duration; // Closest matching alphaTab standard duration enum value + dots: number; // Number of dot annotations (0, 1, or 2) + isApproximate: boolean; // Flag indicating if the duration had to be approximated due to custom fractions +} + +// Available base durations supported natively by alphaTab and standard GP files +const baseDurations: alphaTab.model.Duration[] = [ + alphaTab.model.Duration.Whole, + alphaTab.model.Duration.Half, + alphaTab.model.Duration.Quarter, + alphaTab.model.Duration.Eighth, + alphaTab.model.Duration.Sixteenth, + alphaTab.model.Duration.ThirtySecond, + alphaTab.model.Duration.SixtyFourth +]; + +/** + * Maps a fractional duration tuple [numerator, denominator] from Songsterr's API payload + * to a standardized alphaTab.model.Duration plus any dot values (dotted or double-dotted notes). + * + * Since Songsterr can utilize arbitrary fractional times, this searches through a + * matrix of valid base durations combined with 0 to 2 dots, selecting the combination + * that yields the minimum numeric delta. + * + * @param duration An array containing [numerator, denominator] representing a musical time slice. + * @returns A DurationMappingResult containing mapped duration, dots, and a precision flag. + */ +export function mapSongsterrDuration( + duration: [number, number] | undefined +): DurationMappingResult { + // If no duration is provided, default to a standard quarter note and mark it as approximate. + if (!duration) { + return { + duration: alphaTab.model.Duration.Quarter, + dots: 0, + isApproximate: true + }; + } + + const [numerator, denominator] = duration; + if (!numerator || !denominator) { + return { + duration: alphaTab.model.Duration.Quarter, + dots: 0, + isApproximate: true + }; + } + + // Calculate the numeric target duration value (e.g., 1/4 = 0.25, 3/8 = 0.375) + const targetValue = numerator / denominator; + let bestDuration = alphaTab.model.Duration.Quarter; + let bestDots = 0; + let bestDelta = Number.POSITIVE_INFINITY; + + // Search space: check each base duration against 0, 1, or 2 dots. + for (const candidateDuration of baseDurations) { + // Convert duration enum back to its numeric value (e.g., Duration.Quarter has underlying enum value of 4 -> 1/4) + const baseValue = 1 / Number(candidateDuration); + for (const dots of [0, 1, 2]) { + // Dotted notes add half of the preceding value: + // 1 dot = base + base/2 + // 2 dots = base + base/2 + base/4 + const dottedValue = + baseValue + + (dots >= 1 ? baseValue / 2 : 0) + + (dots >= 2 ? baseValue / 4 : 0); + + const delta = Math.abs(dottedValue - targetValue); + // Track the closest match + if (delta < bestDelta) { + bestDelta = delta; + bestDuration = candidateDuration; + bestDots = dots; + } + } + } + + return { + duration: bestDuration, + dots: bestDots, + // Consider it exact if the delta is within a minuscule threshold + isApproximate: bestDelta > 0.000001 + }; +} + diff --git a/songsterr/instrument-map.ts b/songsterr/instrument-map.ts new file mode 100644 index 0000000..0814cb1 --- /dev/null +++ b/songsterr/instrument-map.ts @@ -0,0 +1,41 @@ +/** + * Represents the MIDI playback configuration for an instrument track. + */ +export interface InstrumentMapping { + program: number; // General MIDI (GM) program number (0-127) + isPercussion: boolean; // Flag identifying if this is a percussion/drum track + primaryChannel?: number; // Primary MIDI channel for audio playback + secondaryChannel?: number; // Secondary MIDI channel for secondary articulations +} + +/** + * Maps a Songsterr instrument ID to a playback mapping configuration compatible with General MIDI. + * + * @param instrumentId The raw instrument ID provided by the Songsterr metadata payload. + * @returns An InstrumentMapping structure indicating MIDI program and channel attributes. + */ +export function mapSongsterrInstrumentToPlayback( + instrumentId: number | undefined +): InstrumentMapping { + // In Songsterr's schema, instrument ID 1024 is reserved for Drums/Percussion. + if (instrumentId === 1024) { + return { + program: 0, // General MIDI standard uses program 0 on Channel 9 for percussion + isPercussion: true, + primaryChannel: 9, // Channel 9 (0-indexed, corresponding to physical channel 10) is the standard percussion channel + secondaryChannel: 9 + }; + } + + // Fallback program defaults to Acoustic Guitar (24) if undefined, or clamps raw IDs between standard GM limits (0-127). + const normalizedProgram = + typeof instrumentId === 'number' + ? Math.min(Math.max(instrumentId, 0), 127) + : 24; + + return { + program: normalizedProgram, + isPercussion: false + }; +} + diff --git a/songsterr/songsterr-to-alphatab.converter.ts b/songsterr/songsterr-to-alphatab.converter.ts new file mode 100644 index 0000000..f214ff6 --- /dev/null +++ b/songsterr/songsterr-to-alphatab.converter.ts @@ -0,0 +1,880 @@ +import * as alphaTab from '@coderline/alphatab'; +import type { + ConversionWarning, + SongsterrRevisionAutomationTempoPoint, + SongsterrRevisionBeatPayload, + SongsterrRevisionNotePayload, + SongsterrRevisionTrackPayload, + SongsterrRevisionVoicePayload, + SongsterrStateMetaCurrent, + SongsterrStateMetaCurrentTrack +} from './types.ts'; +import { mapSongsterrDuration } from './duration-mapper.ts'; +import { mapSongsterrInstrumentToPlayback } from './instrument-map.ts'; + +/** + * Input structure matching metadata to track-specific note revision payloads. + */ +export interface SongsterrRevisionTrackInput { + trackMeta: SongsterrStateMetaCurrentTrack; + revision: SongsterrRevisionTrackPayload; +} + +/** + * Top-level structure containing the inputs required to run the conversion process. + */ +interface SongsterrToGpInput { + meta: SongsterrStateMetaCurrent; + revisions: SongsterrRevisionTrackInput[]; +} + +/** + * Output payload containing the compiled file bytes and conversion warnings. + */ +interface SongsterrToAlphaTabOutput { + data: Uint8Array; + warnings: ConversionWarning[]; +} + +/** + * Intermediary result returned after compiling alphaTab models in memory. + */ +interface BuildScoreResult { + score: alphaTab.model.Score; + settings: alphaTab.Settings; + warnings: ConversionWarning[]; +} + +// Cap to prevent warning list overhead or infinite feedback loops +const MAX_WARNINGS = 200; + +// Mapping of Songsterr dynamics string descriptors to standard alphaTab DynamicValues +const velocityToDynamicMap: Record = { + ppp: alphaTab.model.DynamicValue.PPP, + pp: alphaTab.model.DynamicValue.PP, + p: alphaTab.model.DynamicValue.P, + mp: alphaTab.model.DynamicValue.MP, + mf: alphaTab.model.DynamicValue.MF, + f: alphaTab.model.DynamicValue.F, + ff: alphaTab.model.DynamicValue.FF, + fff: alphaTab.model.DynamicValue.FFF +}; + +// Mapping of Songsterr harmonic type descriptors to standard alphaTab HarmonicTypes +const harmonicTypeMap: Record = { + natural: alphaTab.model.HarmonicType.Natural, + artificial: alphaTab.model.HarmonicType.Artificial, + pinch: alphaTab.model.HarmonicType.Pinch, + tap: alphaTab.model.HarmonicType.Tap, + semi: alphaTab.model.HarmonicType.Semi, + feedback: alphaTab.model.HarmonicType.Feedback +}; + +/** + * Maps a Songsterr tuplet value to an [numerator, denominator] ratio for alphaTab. + * E.g., a standard triplet (3) means: play 3 notes in the duration of 2. + * + * For non-standard/uncommon tuplets, it calculates a reasonable power-of-2 fallback denominator. + * + * @param tuplet Raw tuplet integer factor from Songsterr. + * @returns Tuple representing the tuplet [numerator, denominator]. + */ +function getTupletRatio(tuplet: number): [number, number] { + switch (tuplet) { + case 3: + return [3, 2]; + case 5: + return [5, 4]; + case 6: + return [6, 4]; + case 7: + return [7, 4]; + case 9: + return [9, 8]; + case 10: + return [10, 8]; + case 12: + return [12, 8]; + default: + if (tuplet > 1) { + // Find the nearest lower power of 2 as the denominator (e.g., tuplet of 11 -> plays in space of 8) + const denominator = Math.pow(2, Math.floor(Math.log2(tuplet))); + return [tuplet, denominator]; + } + return [1, 1]; + } +} + +/** + * Dynamic mapping generator that constructs a map of MIDI note numbers to alphaTab's default percussion articulation index. + * + * Guitar Pro format files (GP7) represent percussion/drum articulations not by raw MIDI note values, + * but by their index position within the track's percussion articulation array. This function + * instantiates a dummy score, exports it to GP7, reimports it, and registers the returned + * articulation array order to bypass hardcoded lookup maps. + * + * @returns Map matching MIDI note numbers to their local zero-indexed drum articulation position. + */ +function buildPercussionIndexMap(): Map { + const score = new alphaTab.model.Score(); + const mb = new alphaTab.model.MasterBar(); + score.addMasterBar(mb); + const track = new alphaTab.model.Track(); + track.playbackInfo.primaryChannel = 9; + track.playbackInfo.secondaryChannel = 9; + const staff = new alphaTab.model.Staff(); + staff.isPercussion = true; + track.addStaff(staff); + const bar = new alphaTab.model.Bar(); + const voice = new alphaTab.model.Voice(); + const beat = new alphaTab.model.Beat(); + beat.isEmpty = true; + voice.addBeat(beat); + bar.addVoice(voice); + staff.addBar(bar); + score.addTrack(track); + + const settings = new alphaTab.Settings(); + score.finish(settings); + const exporter = new alphaTab.exporter.Gp7Exporter(); + const data = exporter.export(score, settings); + const reimported = alphaTab.importer.ScoreLoader.loadScoreFromBytes( + data, + settings + ); + + const map = new Map(); + const articulations = reimported.tracks[0].percussionArticulations; + for (let i = 0; i < articulations.length; i++) { + const id = articulations[i].id; + if (!map.has(id)) { + map.set(id, i); + } + } + return map; +} + +// Singleton cache for the percussion mapping table +let percussionIndexMap: Map | null = null; + +/** + * Retrieves the local Guitar Pro drum articulation index corresponding to a standard MIDI note. + * + * @param midiNote General MIDI drum note (e.g., 38 for snare, 36 for kick). + * @returns Articulation index inside the GP score model. + */ +function getPercussionArticulationIndex(midiNote: number): number { + if (!percussionIndexMap) { + percussionIndexMap = buildPercussionIndexMap(); + } + return percussionIndexMap.get(midiNote) ?? midiNote; +} + +/** + * Core translator class converting rich Songsterr track JSON databases to standard Guitar Pro 7 binary. + */ +export class SongsterrToAlphaTabConverter { + /** + * Translates the Songsterr payload into standard Guitar Pro 7 (.gp) binary bytes. + * + * @param input Collected Songsterr metadata and note payloads. + * @returns Binary array representing GP7 file data. + */ + toGp7(input: SongsterrToGpInput): SongsterrToAlphaTabOutput { + const { score, settings, warnings } = this.buildScore(input); + + const exporter = new alphaTab.exporter.Gp7Exporter(); + const data = exporter.export(score, settings); + + return { data, warnings }; + } + + /** + * Translates the Songsterr payload into a standard MIDI (.mid) file. + * + * @param input Collected Songsterr metadata and note payloads. + * @returns Binary array representing standard MIDI file data. + */ + toMidi(input: SongsterrToGpInput): SongsterrToAlphaTabOutput { + const { score, settings, warnings } = this.buildScore(input); + + const midiFile = new alphaTab.midi.MidiFile(); + const handler = new alphaTab.midi.AlphaSynthMidiFileHandler(midiFile, true); + const generator = new alphaTab.midi.MidiFileGenerator(score, settings, handler); + generator.generate(); + const data = midiFile.toBinary(); + + return { data, warnings }; + } + + /** + * Top-down builder compiling alphaTab objects from Songsterr inputs. + */ + private buildScore({ meta, revisions }: SongsterrToGpInput): BuildScoreResult { + const warnings: ConversionWarning[] = []; + + const score = new alphaTab.model.Score(); + score.title = meta.title; + score.artist = meta.artist; + score.tab = 'Songsterr Downloader'; + + // Locate the longest/most complete track to represent the master timeline baseline + const masterTrack = this.pickMasterTrack(revisions); + const masterBarCount = Math.max(1, this.getMasterBarCount(revisions)); + + // Construct MasterBars representing time signatures, section tags, repeats, and tempo automations + this.buildMasterBars({ + score, + masterTrack, + masterBarCount, + warnings + }); + + let nextChannel = 0; + // Iterate and build individual tracks + for (const entry of revisions) { + const instrumentId = + entry.trackMeta.instrumentId ?? entry.revision.instrumentId; + const isPercussion = instrumentId === 1024 || !!entry.trackMeta.isDrums; + let channel: number; + if (isPercussion) { + channel = 9; // Percussion is locked to Channel 9 (MIDI 10) + } else { + if (nextChannel === 9) nextChannel++; // Skip channel 9 to prevent drum sound bleed on standard instruments + channel = nextChannel; + nextChannel++; + } + this.buildTrack({ + score, + entry, + masterBarCount, + warnings, + channel + }); + } + + const settings = new alphaTab.Settings(); + // Finish compiles the score model and runs checks on bars, voices, and indices + score.finish(settings); + + return { score, settings, warnings }; + } + + /** + * Compiles and appends master bars to the global score. + * Coordinates global properties like repeats, alternate endings, time signatures, and tempos. + */ + private buildMasterBars({ + score, + masterTrack, + masterBarCount, + warnings + }: { + score: alphaTab.model.Score; + masterTrack: SongsterrRevisionTrackPayload | null; + masterBarCount: number; + warnings: ConversionWarning[]; + }): void { + let timeSignatureNumerator = 4; + let timeSignatureDenominator = 4; + + for (let index = 0; index < masterBarCount; index++) { + const measure = masterTrack?.measures?.[index]; + const signature = this.getValidSignature(measure?.signature); + // Propagate time signature changes down the timeline + if (signature) { + [timeSignatureNumerator, timeSignatureDenominator] = signature; + } + + const masterBar = new alphaTab.model.MasterBar(); + masterBar.timeSignatureNumerator = timeSignatureNumerator; + masterBar.timeSignatureDenominator = timeSignatureDenominator; + + // Extract section markers ("Intro", "Verse", "Chorus", etc.) + if (measure?.marker) { + const section = new alphaTab.model.Section(); + const markerText = this.extractMarkerText(measure.marker); + section.marker = markerText; + section.text = markerText; + masterBar.section = section; + } + + // Handle Repeat bracket structures + if (measure?.repeatStart) { + masterBar.isRepeatStart = true; + } + + if (typeof measure?.repeatCount === 'number' && measure.repeatCount > 0) { + masterBar.repeatCount = measure.repeatCount; + } + + // Handle alternate endings (e.g., 1st ending, 2nd ending) + if ( + typeof measure?.alternateEnding === 'number' && + measure.alternateEnding > 0 + ) { + masterBar.alternateEndings = measure.alternateEnding; + } + + score.addMasterBar(masterBar); + } + + // Process global tempo changes + const tempoPoints = this.findTempoPoints(masterTrack); + this.applyTempoAutomations(score, tempoPoints, warnings); + } + + /** + * Constructs an individual track (instrument voice), populates it with bars, + * scans and formats voice dimensions, and attaches it to the score. + */ + private buildTrack({ + score, + entry, + masterBarCount, + warnings, + channel + }: { + score: alphaTab.model.Score; + entry: SongsterrRevisionTrackInput; + masterBarCount: number; + warnings: ConversionWarning[]; + channel: number; + }): void { + const { trackMeta, revision } = entry; + const playbackMapping = mapSongsterrInstrumentToPlayback( + trackMeta.instrumentId ?? revision.instrumentId + ); + + const track = new alphaTab.model.Track(); + track.name = trackMeta.title || trackMeta.name || revision.name || 'Track'; + track.shortName = track.name.slice(0, 20); + track.playbackInfo.program = playbackMapping.program; + track.playbackInfo.primaryChannel = channel; + track.playbackInfo.secondaryChannel = channel; + + const staff = new alphaTab.model.Staff(); + const tuning = revision.tuning || trackMeta.tuning; + if (Array.isArray(tuning) && tuning.length > 0) { + staff.stringTuning = new alphaTab.model.Tuning('Custom', tuning, false); + } + const isPercussion = playbackMapping.isPercussion || !!trackMeta.isDrums; + staff.isPercussion = isPercussion; + const numStrings = Array.isArray(tuning) ? tuning.length : 6; + + // CRITICAL BUG PREVENTION: Pre-scan to determine the maximum voice count across all measures. + // alphaTab expects every single bar in a staff to have the same number of voices. + // If a measure has 2 voices (e.g., fingerstyle melody + bassline) but a neighbouring measure only + // has 1, the compiler will crash inside Voice._chain when calling score.finish(). + // We pad shorter measures with empty rest voices to match the maximum. + let maxVoiceCount = 1; + for (let i = 0; i < masterBarCount; i++) { + const m = revision.measures?.[i]; + maxVoiceCount = Math.max(maxVoiceCount, m?.voices?.length || 0); + } + + for (let measureIndex = 0; measureIndex < masterBarCount; measureIndex++) { + const bar = new alphaTab.model.Bar(); + const measure = revision.measures?.[measureIndex]; + const voiceCount = measure?.voices?.length || 0; + + if (voiceCount === 0) { + // Empty measure: pad with a single rest voice populated with silent beats + const voice = new alphaTab.model.Voice(); + this.fillWithRestBeats(voice, score.masterBars[measureIndex]); + bar.addVoice(voice); + } else { + // Process and append all voices + for (let voiceIndex = 0; voiceIndex < voiceCount; voiceIndex++) { + const voice = new alphaTab.model.Voice(); + const sourceVoice = measure!.voices![voiceIndex]; + + this.fillVoice({ + voice, + sourceVoice, + masterBar: score.masterBars[measureIndex], + warnings, + locationPrefix: `track:${trackMeta.partId}|measure:${measureIndex}|voice:${voiceIndex}`, + isPercussion, + numStrings + }); + + bar.addVoice(voice); + } + } + + // Pad remaining empty slots with silent rest voices to match maxVoiceCount + for (let v = bar.voices.length; v < maxVoiceCount; v++) { + const restVoice = new alphaTab.model.Voice(); + this.fillWithRestBeats(restVoice, score.masterBars[measureIndex]); + bar.addVoice(restVoice); + } + + staff.addBar(bar); + } + + track.addStaff(staff); + score.addTrack(track); + } + + /** + * Fills an alphaTab Voice with translated beats parsed from a Songsterr Voice payload. + */ + private fillVoice({ + voice, + sourceVoice, + masterBar, + warnings, + locationPrefix, + isPercussion, + numStrings + }: { + voice: alphaTab.model.Voice; + sourceVoice: SongsterrRevisionVoicePayload | undefined; + masterBar: alphaTab.model.MasterBar; + warnings: ConversionWarning[]; + locationPrefix: string; + isPercussion: boolean; + numStrings: number; + }): void { + const beats = sourceVoice?.beats || []; + + // Fallback if the voice is entirely silent + if (beats.length === 0 || sourceVoice?.rest) { + this.fillWithRestBeats(voice, masterBar); + return; + } + + for (let beatIndex = 0; beatIndex < beats.length; beatIndex++) { + const beatData = beats[beatIndex]; + const beat = this.mapBeat( + beatData, + warnings, + `${locationPrefix}|beat:${beatIndex}`, + isPercussion, + numStrings + ); + voice.addBeat(beat); + } + + if (voice.beats.length === 0) { + this.fillWithRestBeats(voice, masterBar); + } + } + + /** + * Maps detailed musical beat information (tuplets, lyrics/texts, pick strokes, velocities, dynamics) to alphaTab models. + */ + private mapBeat( + beatData: SongsterrRevisionBeatPayload, + warnings: ConversionWarning[], + location: string, + isPercussion: boolean, + numStrings: number + ): alphaTab.model.Beat { + const beat = new alphaTab.model.Beat(); + + // Mark rest beats + if (beatData.rest) { + beat.isEmpty = true; + } + + // Convert duration structure + const mappedDuration = mapSongsterrDuration(beatData.duration); + beat.duration = mappedDuration.duration; + beat.dots = beatData.dots ?? mappedDuration.dots; + const rawText = beatData.text; + const textStr = typeof rawText === 'string' ? rawText : rawText?.text; + beat.text = (textStr as string | undefined) || null; + + if (mappedDuration.isApproximate && !beatData.tuplet) { + this.pushWarning(warnings, { + code: 'duration_approximated', + message: `Approximated unsupported duration ${JSON.stringify( + beatData.duration + )}`, + location + }); + } + + // Process tuplets (e.g., triplets, quintuplets, etc.) + if (typeof beatData.tuplet === 'number' && beatData.tuplet > 1) { + const [num, den] = getTupletRatio(beatData.tuplet); + beat.tupletNumerator = num; + beat.tupletDenominator = den; + + // When mapping a tuplet, the underlying duration should match the base time value + // of the notes, as the tuplet fraction itself modifies the duration. + if (typeof beatData.type === 'number' && beatData.type > 0) { + const baseDuration = mapSongsterrDuration([1, beatData.type]); + beat.duration = baseDuration.duration; + beat.dots = beatData.dots ?? 0; + } + } + + // Apply MIDI velocity values to dynamics + if (typeof beatData.velocity === 'string') { + const mappedDynamic = + velocityToDynamicMap[beatData.velocity.toLowerCase()]; + if (typeof mappedDynamic === 'number') { + beat.dynamics = mappedDynamic; + } else { + this.pushWarning(warnings, { + code: 'velocity_unknown', + message: `Unsupported beat velocity "${beatData.velocity}"`, + location + }); + } + } + + // Map pick stroke directions + if (typeof beatData.pickStroke === 'string') { + const ps = beatData.pickStroke.toLowerCase(); + if (ps === 'down') { + beat.pickStroke = alphaTab.model.PickStroke.Down; + } else if (ps === 'up') { + beat.pickStroke = alphaTab.model.PickStroke.Up; + } + } + + // Map beat-level vibratos + if (beatData.wideVibrato || beatData.vibratoWithTremoloBar) { + beat.vibrato = alphaTab.model.VibratoType.Wide; + } else if (beatData.vibrato) { + beat.vibrato = alphaTab.model.VibratoType.Slight; + } + + // Iterate notes present on this beat + const notes = beatData.notes || []; + for (let noteIndex = 0; noteIndex < notes.length; noteIndex++) { + const noteData = notes[noteIndex]; + if (noteData.rest) { + continue; + } + const note = this.mapNote( + noteData, + beatData, + warnings, + `${location}|note:${noteIndex}`, + isPercussion, + numStrings + ); + beat.addNote(note); + } + + return beat; + } + + /** + * Maps a single note and its underlying technical performance properties + * (ties, dead/muted notes, ghost notes, legato hammer-ons/pull-offs, slides, harmonics, bends) to alphaTab models. + */ + private mapNote( + noteData: SongsterrRevisionNotePayload, + beatData: SongsterrRevisionBeatPayload, + warnings: ConversionWarning[], + location: string, + isPercussion: boolean, + numStrings: number + ): alphaTab.model.Note { + const note = new alphaTab.model.Note(); + + // STRING MAPPING: + // Songsterr: 0 = highest pitch string. + // alphaTab: 1 = lowest pitch string. + // Thus we invert the string index: string = totalStrings - songsterrString + note.string = isPercussion ? -1 : numStrings - (noteData.string ?? 0); + note.fret = noteData.fret ?? 0; + + // Set percussion articulation reference if this is a drum track + if (isPercussion) { + note.percussionArticulation = getPercussionArticulationIndex( + noteData.fret ?? 0 + ); + } + + // Tie notes together + if (noteData.tie) { + note.isTieDestination = true; + } + + // Dead / muted notes + if (noteData.dead) { + note.isDead = true; + } + + // Ghost (bracketed/soft) notes + if (noteData.ghost) { + note.isGhost = true; + } + + // Hammer-on or pull-off (legato transition) + if (noteData.hp) { + note.isHammerPullOrigin = true; + } + + // Staccato articulation + if (noteData.staccato) { + note.isStaccato = true; + } + + // Accentuated articulation + if (noteData.accentuated) { + note.accentuated = alphaTab.model.AccentuationType.Normal; + } + + // Palm muting (inherited from the parent beat level) + if (beatData.palmMute) { + note.isPalmMute = true; + } + + // Note-level pitch vibrato (takes priority over beat level) + if (noteData.wideVibrato) { + note.vibrato = alphaTab.model.VibratoType.Wide; + } else if (noteData.vibrato) { + note.vibrato = alphaTab.model.VibratoType.Slight; + } + + // Slide effects (legato, shift, slide in, slide out) + if (typeof noteData.slide === 'string') { + this.mapSlide(note, noteData.slide, warnings, location); + } + + // Harmonics (natural, artificial, pinch, tap, feedback) + if (typeof noteData.harmonic === 'string') { + this.mapHarmonic(note, noteData, warnings, location); + } + + // Pitch bends + if (noteData.bend && noteData.bend.points && noteData.bend.points.length > 0) { + this.mapBend(note, noteData.bend); + } + + return note; + } + + /** + * Maps Songsterr slide terms to alphaTab's input and output slide types. + */ + private mapSlide( + note: alphaTab.model.Note, + slide: string, + warnings: ConversionWarning[], + location: string + ): void { + const normalizedSlide = slide.toLowerCase(); + if (normalizedSlide === 'shift') { + note.slideOutType = alphaTab.model.SlideOutType.Shift; + return; + } + if (normalizedSlide === 'legato') { + note.slideOutType = alphaTab.model.SlideOutType.Legato; + return; + } + if (normalizedSlide === 'into_from_below' || normalizedSlide === 'below') { + note.slideInType = alphaTab.model.SlideInType.IntoFromBelow; + return; + } + if (normalizedSlide === 'into_from_above') { + note.slideInType = alphaTab.model.SlideInType.IntoFromAbove; + return; + } + if (normalizedSlide === 'out_up') { + note.slideOutType = alphaTab.model.SlideOutType.OutUp; + return; + } + if (normalizedSlide === 'out_down' || normalizedSlide === 'downwards') { + note.slideOutType = alphaTab.model.SlideOutType.OutDown; + return; + } + + this.pushWarning(warnings, { + code: 'slide_unsupported', + message: `Unsupported slide effect "${slide}"`, + location + }); + } + + /** + * Maps Songsterr harmonic terms to alphaTab's harmonic types and sets the harmonic center fret. + */ + private mapHarmonic( + note: alphaTab.model.Note, + noteData: SongsterrRevisionNotePayload, + warnings: ConversionWarning[], + location: string + ): void { + const harmonicStr = noteData.harmonic!.toLowerCase(); + const mappedType = harmonicTypeMap[harmonicStr]; + + if (typeof mappedType === 'number') { + note.harmonicType = mappedType; + if (typeof noteData.harmonicFret === 'number') { + note.harmonicValue = noteData.harmonicFret; + } + } else { + this.pushWarning(warnings, { + code: 'harmonic_unsupported', + message: `Unsupported harmonic type "${noteData.harmonic}"`, + location + }); + } + } + + /** + * Maps Songsterr bend vectors to alphaTab BendPoints. + * + * FORMULA DETAILS: + * - Songsterr coordinates: position ranges from 0 to 60. + * - Guitar Pro and Songsterr scale: 100 pitch units = 1 full tone (2 semitones). 50 units = 1 semitone. + * - alphaTab BendPoint.value expects quarter-steps (where 25 GP units = 1 unit, MaxValue = 12, matching 3 full tones). + * - Conversion: alphaTab_value = Songsterr_tone / 25 + */ + private mapBend( + note: alphaTab.model.Note, + bend: { tone: number; points: { position: number; tone: number }[] } + ): void { + note.bendType = alphaTab.model.BendType.Custom; + + for (const point of bend.points) { + const offset = Math.round(point.position); + const value = Math.round(point.tone / 25); + note.addBendPoint(new alphaTab.model.BendPoint(offset, value)); + } + } + + /** + * Fills an empty voice with a string of silent rest beats matching the time signature dimensions of the MasterBar. + */ + private fillWithRestBeats( + voice: alphaTab.model.Voice, + masterBar: alphaTab.model.MasterBar + ): void { + const denominator = masterBar.timeSignatureDenominator || 4; + const numerator = masterBar.timeSignatureNumerator || 4; + + const mappedDuration = mapSongsterrDuration([1, denominator]); + for (let i = 0; i < numerator; i++) { + const restBeat = new alphaTab.model.Beat(); + restBeat.isEmpty = true; + restBeat.duration = mappedDuration.duration; + restBeat.dots = mappedDuration.dots; + voice.addBeat(restBeat); + } + } + + /** + * Scans and returns the highest measure count found across all track revisions. + */ + private getMasterBarCount(revisions: SongsterrRevisionTrackInput[]): number { + return revisions.reduce((max, entry) => { + return Math.max(max, entry.revision?.measures?.length || 0); + }, 0); + } + + /** + * Identifies the track revision containing the largest number of measures to use as the master sync track. + */ + private pickMasterTrack( + revisions: SongsterrRevisionTrackInput[] + ): SongsterrRevisionTrackPayload | null { + if (revisions.length === 0) { + return null; + } + + return revisions.reduce((longest, current) => { + const currentLength = current.revision?.measures?.length || 0; + const longestLength = longest.revision?.measures?.length || 0; + return currentLength > longestLength ? current : longest; + }).revision; + } + + /** + * Helper verifying if a time signature array is complete and valid. + */ + private getValidSignature( + signature: [number, number] | undefined + ): [number, number] | null { + if (!Array.isArray(signature) || signature.length !== 2) { + return null; + } + const [numerator, denominator] = signature; + if (!numerator || !denominator) { + return null; + } + return [numerator, denominator]; + } + + /** + * Helper extracting clean text from complex marker payloads. + */ + private extractMarkerText(marker: string | { text: string; width?: number }): string { + if (typeof marker === 'string') { + return marker; + } + if (marker && typeof marker === 'object' && typeof marker.text === 'string') { + return marker.text; + } + return ''; + } + + /** + * Extracts tempo change points defined in the track revision. + */ + private findTempoPoints( + masterTrack: SongsterrRevisionTrackPayload | null + ): SongsterrRevisionAutomationTempoPoint[] { + const tempo = masterTrack?.automations?.tempo; + return Array.isArray(tempo) ? tempo : []; + } + + /** + * Applies tempo automation objects to the corresponding MasterBars in the score. + */ + private applyTempoAutomations( + score: alphaTab.model.Score, + points: SongsterrRevisionAutomationTempoPoint[], + warnings: ConversionWarning[] + ): void { + for (const point of points) { + const barIndex = point.measure; + const masterBar = score.masterBars[barIndex]; + if (!masterBar) { + this.pushWarning(warnings, { + code: 'tempo_measure_out_of_range', + message: `Tempo automation references missing measure ${barIndex}`, + location: `measure:${barIndex}` + }); + continue; + } + + // alphaTab reference indices: 0=whole, 1=half(×0.5), 2=quarter(×1.0), 3=dotted-quarter(×1.5), 4=half(×2.0), 5=dotted-half(×3.0) + // Since Songsterr's BPM is always based on quarter notes, we use reference 2. + const tempoReference = 2; + const ratioPosition = + point.position > 0 + ? Math.max(0, Math.min(1, point.position / (point.type || 4))) + : 0; + const tempoAutomation = alphaTab.model.Automation.buildTempoAutomation( + false, + ratioPosition, + point.bpm, + tempoReference, + true + ); + masterBar.tempoAutomations.push(tempoAutomation); + } + } + + /** + * Appends a conversion warning if the threshold limit has not been reached. + */ + private pushWarning( + warnings: ConversionWarning[], + warning: ConversionWarning + ): void { + if (warnings.length < MAX_WARNINGS) { + warnings.push(warning); + } + } +} diff --git a/songsterr/types.ts b/songsterr/types.ts new file mode 100644 index 0000000..daa843e --- /dev/null +++ b/songsterr/types.ts @@ -0,0 +1,163 @@ +/** + * Raw response format received when initiating a file download from Songsterr. + */ +export interface SongsterrDownloadResponse { + file: number[]; // Binary array representation of the downloaded file bytes + fileName: string; // The original filename served by the server + contentType: string; // The MIME type of the file +} + +/** + * Basic metadata subset returned by Songsterr search endpoints. + */ +export interface SongsterrPartialMetadata { + title: string; // Title of the song + songId: number; // Global identifier for the song + artistId: number; // Global identifier for the artist + artist: string; // Artist name + byLinkUrl?: string; // Optional absolute link URL +} + +/** + * Represents metadata of an individual instrument track in Songsterr's state payload. + */ +export interface SongsterrStateMetaCurrentTrack { + partId: number; // Part ID used to fetch the track-specific note payload JSON + instrumentId: number; // Instrument ID used for MIDI playback assignment + title?: string; // Custom track title (alternative) + name?: string; // Custom track name + tuning?: number[]; // Tuning offsets in semitones starting from highest pitch + isDrums?: boolean; // Flag indicating if this is a percussion track +} + +/** + * The top-level metadata container for the current song state scraped from Songsterr's HTML script block. + */ +export interface SongsterrStateMetaCurrent { + songId: number; // Song ID + revisionId: number; // Active musical notation revision ID + image: string; // Storage hash/image suffix identifier on CloudFront CDNs + title: string; // Title of the song + artist: string; // Name of the artist + tracks: SongsterrStateMetaCurrentTrack[]; // Collection of tracks associated with this revision +} + +/** + * A tempo variation marker inside the song's automation timeline. + */ +export interface SongsterrRevisionAutomationTempoPoint { + measure: number; // 0-indexed measure where the tempo change occurs + position: number; // Metric position within the measure + bpm: number; // Beats Per Minute (tempo target) + type: number; // Time division duration type index +} + +/** + * Collection of performance automations (such as tempo changes) present in a revision. + */ +export interface SongsterrRevisionAutomations { + tempo?: SongsterrRevisionAutomationTempoPoint[]; +} + +/** + * Coordinate specifying a single point on a pitch bend diagram. + */ +export interface SongsterrRevisionBendPoint { + position: number; // Horizontal offset on bend diagram (0 to 60) + tone: number; // Pitch offset value (100 = 1 full tone, 50 = 1 semitone) +} + +/** + * Pitch bend configuration payload for a note. + */ +export interface SongsterrRevisionBendPayload { + tone: number; // Max bend tone value + points: SongsterrRevisionBendPoint[]; // Set of coordinate points defining the bend curve +} + +/** + * Playback modification, articulation, or fret details of an individual note. + */ +export interface SongsterrRevisionNotePayload { + fret?: number; // Fret number on the fretboard (0 = open string) + string?: number; // 0-indexed string index (0 is typically the highest pitch string) + tie?: boolean; // Identifies if this note is tied to the previous note + slide?: string; // String code representing slide type ('shift', 'legato', 'below', etc.) + rest?: boolean; // Identifies if the note is a rest + dead?: boolean; // Identifies dead (muted) notes + ghost?: boolean; // Identifies ghost/bracketed notes + hp?: boolean; // Legato hammer-on or pull-off flag + staccato?: boolean; // Articulation indicating a shortened note duration + accentuated?: boolean;// Flag indicating heavier picking velocity + vibrato?: boolean; // Subtle pitch vibrato + wideVibrato?: boolean;// Intense pitch vibrato + harmonic?: string; // Harmonic type ('natural', 'artificial', 'pinch', etc.) + harmonicFret?: number;// Fret location where the harmonic is initiated + bend?: SongsterrRevisionBendPayload; // Pitch bend details +} + +/** + * Musical beat payload grouping multiple notes together played at the same instant. + */ +export interface SongsterrRevisionBeatPayload { + notes?: SongsterrRevisionNotePayload[]; // Collection of notes active in this beat + type?: number; // Base beat duration category + duration?: [number, number]; // Fractional duration tuple [numerator, denominator] + dots?: number; // Count of dots modifying the beat's length + text?: string | { text: string; width?: number }; // Custom lyric/annotation text on the staff + velocity?: string; // MIDI dynamics code ('p', 'mp', 'f', etc.) + rest?: boolean; // Flag identifying if this beat is a pure rest + palmMute?: boolean; // Flag identifying if palm-muting is active for this beat + vibrato?: boolean; // Beat-level vibrato + wideVibrato?: boolean;// Beat-level wide vibrato + vibratoWithTremoloBar?: string; // Tremolo bar vibrato depth code + pickStroke?: string; // Picking direction ('up', 'down') + tuplet?: number; // Tuplet factor (e.g., 3 for triplets, 5 for quintuplets) + tupletStart?: boolean;// Flag identifying the start of a tuplet grouping + tupletStop?: boolean; // Flag identifying the end of a tuplet grouping +} + +/** + * Represents a vocal line/independent voice track containing sequential beats within a measure. + */ +export interface SongsterrRevisionVoicePayload { + beats?: SongsterrRevisionBeatPayload[]; // Beats sequenced inside this voice + rest?: boolean; // Flag identifying if the voice is entirely silent +} + +/** + * Represents a single measure (bar) in a track containing multiple voices. + */ +export interface SongsterrRevisionMeasurePayload { + voices?: SongsterrRevisionVoicePayload[]; // Independent musical voices in this bar + signature?: [number, number]; // Time signature tuple [numerator, denominator] (e.g. [4, 4]) + marker?: string | { text: string; width?: number }; // Section marker name (e.g. "Verse", "Chorus") + repeatStart?: boolean;// Identifies if a repeat section opens at this measure + repeatCount?: number; // Count of repeat iterations + alternateEnding?: number; // Alternate ending bracket index/value + rest?: boolean; // Identifies if the entire measure is silent +} + +/** + * Complete musical score revision details for a single track. + */ +export interface SongsterrRevisionTrackPayload { + name?: string; // Track name + instrumentId?: number;// Instrument playback configuration ID + tuning?: number[]; // Note offsets for each string + strings?: number; // Number of strings on the instrument + measures: SongsterrRevisionMeasurePayload[]; // Complete sequence of measures (bars) + automations?: SongsterrRevisionAutomations; // Set of playback automations + songId?: number; // Reference song identifier + revisionId?: number; // Reference revision identifier + partId?: number; // Reference part identifier +} + +/** + * Warning payload generated during the parsing/conversion process. + */ +export interface ConversionWarning { + code: string; // Unique warning code identifier (e.g., 'duration_approximated') + message: string; // Human-readable warning description + location?: string; // Code location indicating where the issue was hit (e.g., 'track:1|measure:12') +} diff --git a/songsterr_dl.ts b/songsterr_dl.ts new file mode 100644 index 0000000..99c6e04 --- /dev/null +++ b/songsterr_dl.ts @@ -0,0 +1,181 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { SongsterrToAlphaTabConverter } from './songsterr/songsterr-to-alphatab.converter.ts'; +import type { SongsterrStateMetaCurrent, SongsterrRevisionTrackPayload } from './songsterr/types.ts'; + +// Polite standard User-Agent header to prevent aggressive scraper blocks +const userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; + +/** + * Fetches the HTML content of a URL using the global fetch API. + * + * @param url Target endpoint to fetch. + * @returns HTML string content. + */ +async function fetchHTML(url: string): Promise { + const res = await fetch(url, { + headers: { + 'User-Agent': userAgent + } + }); + if (!res.ok) { + throw new Error(`Failed to fetch ${url} (HTTP ${res.status})`); + } + return res.text(); +} + +/** + * Fetches and parses JSON from a URL using the global fetch API. + * + * @param url Target JSON API endpoint. + * @returns Parsed JSON object. + */ +async function fetchJSON(url: string): Promise { + const res = await fetch(url, { + headers: { + 'User-Agent': userAgent + } + }); + if (!res.ok) { + throw new Error(`Failed to fetch JSON ${url} (HTTP ${res.status})`); + } + return res.json(); +} + +/** + * Sanitizes a filename string by replacing illegal OS characters with underscores. + * + * @param name File name to clean. + * @returns Cleaned file name. + */ +function sanitizeFilename(name: string): string { + return name.replace(/[\\/:*?"<>|]/g, '_'); +} + +/** + * Main execution entry point for the Songsterr download and conversion helper. + */ +async function main() { + const args = process.argv.slice(2); + let songUrl = ''; + let outputDir = '.'; + + // Parse custom command-line arguments: --url and --output + for (let i = 0; i < args.length; i++) { + if (args[i] === '--url' && i + 1 < args.length) { + songUrl = args[i + 1]; + i++; + } else if (args[i] === '--output' && i + 1 < args.length) { + outputDir = args[i + 1]; + i++; + } + } + + if (!songUrl) { + console.error('Error: Missing --url parameter'); + process.exit(1); + } + + try { + console.log(`[*] Fetching Songsterr page: ${songUrl}`); + const html = await fetchHTML(songUrl); + + // Locate the embedded React hydration state JSON script tag + const stateMatch = html.match(/