├── version └── version.json ├── .vscode └── settings.json ├── .DS_Store ├── dab-downloader-test ├── screenshots ├── ScreenShot1.png └── ScreenShot2.png ├── .gitignore ├── colours.go ├── spotify_types.go ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── pull_request_template.md │ ├── question.yml │ ├── feature_request.yml │ └── bug_report.yml └── workflows │ └── release.yml ├── navidrome_types.go ├── config └── example-config.json ├── docker-compose.yml ├── go.mod ├── Dockerfile ├── ffmpeg.go ├── search.go ├── retry.go ├── spotify.go ├── debug.go ├── updater.go ├── types.go ├── warning_collector.go ├── utils.go ├── artist_downloader.go ├── musicbrainz.go ├── navidrome.go ├── metadata.go ├── api.go ├── downloader.go ├── README.md └── main.go /version/version.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.9.6-dev" 3 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "kiroAgent.configureMCP": "Disabled" 3 | } -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrathxmOp/dab-downloader/HEAD/.DS_Store -------------------------------------------------------------------------------- /dab-downloader-test: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrathxmOp/dab-downloader/HEAD/dab-downloader-test -------------------------------------------------------------------------------- /screenshots/ScreenShot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrathxmOp/dab-downloader/HEAD/screenshots/ScreenShot1.png -------------------------------------------------------------------------------- /screenshots/ScreenShot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrathxmOp/dab-downloader/HEAD/screenshots/ScreenShot2.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config/config.json 2 | dab-downloader 3 | config.json 4 | dab-downloader-linux-amd64 5 | *.md 6 | !README.md 7 | verification_test.go -------------------------------------------------------------------------------- /colours.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/fatih/color" 4 | 5 | // Package-level color variables 6 | var ( 7 | colorInfo = color.New(color.FgCyan) 8 | colorSuccess = color.New(color.FgGreen) 9 | colorWarning = color.New(color.FgYellow) 10 | colorError = color.New(color.FgRed) 11 | colorPrompt = color.New(color.FgBlue, color.Bold) // Added for user prompts 12 | ) 13 | -------------------------------------------------------------------------------- /spotify_types.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/zmb3/spotify/v2" 5 | ) 6 | 7 | // SpotifyClient holds the spotify client and other required fields 8 | 9 | type SpotifyClient struct { 10 | client *spotify.Client 11 | ID string 12 | Secret string 13 | } 14 | 15 | // NewSpotifyClient creates a new spotify client 16 | func NewSpotifyClient(id, secret string) *SpotifyClient { 17 | return &SpotifyClient{ 18 | ID: id, 19 | Secret: secret, 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 💬 General Discussion 4 | url: https://github.com/PrathxmOp/dab-downloader/discussions 5 | about: Ask questions, share ideas, and discuss with the community 6 | - name: 📖 Documentation 7 | url: https://github.com/PrathxmOp/dab-downloader#readme 8 | about: Read the comprehensive documentation and usage guide 9 | - name: 🔒 Security Issue 10 | url: https://github.com/PrathxmOp/dab-downloader/security/advisories/new 11 | about: Report a security vulnerability (private disclosure) -------------------------------------------------------------------------------- /navidrome_types.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | subsonic "github.com/delucks/go-subsonic" 5 | ) 6 | 7 | // NavidromeClient holds the navidrome client and other required fields 8 | 9 | type NavidromeClient struct { 10 | URL string 11 | Username string 12 | Password string 13 | Client subsonic.Client 14 | Salt string 15 | Token string 16 | } 17 | 18 | // NewNavidromeClient creates a new navidrome client 19 | func NewNavidromeClient(url, username, password string) *NavidromeClient { 20 | return &NavidromeClient{ 21 | URL: url, 22 | Username: username, 23 | Password: password, 24 | } 25 | } -------------------------------------------------------------------------------- /config/example-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "APIURL": "https://your-dab-api-url.com", 3 | "DownloadLocation": "/path/to/your/music/folder", 4 | "Parallelism": 5, 5 | "SpotifyClientID": "YOUR_SPOTIFY_CLIENT_ID", 6 | "SpotifyClientSecret": "YOUR_SPOTIFY_CLIENT_SECRET", 7 | "NavidromeURL": "https://your-navidrome-url.com", 8 | "NavidromeUsername": "your_navidrome_username", 9 | "NavidromePassword": "your_navidrome_password", 10 | "Format": "flac", 11 | "Bitrate": "320", 12 | "saveAlbumArt": false, 13 | "naming": { 14 | "album_folder_mask": "{artist}/{artist} - {album} ({year})", 15 | "ep_folder_mask": "{artist}/EPs/{artist} - {album} ({year})", 16 | "single_folder_mask": "{artist}/Singles/{artist} - {album} ({year})", 17 | "file_mask": "{track_number} - {artist} - {title}" 18 | } 19 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | dab-downloader: 5 | image: prathxm/dab-downloader:latest 6 | container_name: dab-downloader 7 | volumes: 8 | # Mount a local directory for configuration (e.g., config.json) 9 | - ./config:/app/config 10 | # Mount a local directory for downloaded music 11 | - ./music:/app/music 12 | # You can pass command-line arguments to the downloader here 13 | # For example, to run a search: 14 | # command: search "query" --type album 15 | # To run the downloader interactively, you might remove the 'command' line 16 | # and use 'docker compose run dab-downloader ' 17 | # environment: 18 | # - API_URL=https://dab.yeet.su 19 | # - SPOTIFY_CLIENT_ID=your_spotify_client_id 20 | # - SPOTIFY_CLIENT_SECRET=your_spotify_client_secret 21 | # - NAVIDROME_URL=https://your_navidrome_url.com 22 | # - NAVIDROME_USERNAME=your_navidrome_username 23 | # - NAVIDROME_PASSWORD=your_navidrome_password 24 | restart: "no" 25 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module dab-downloader 2 | 3 | go 1.21 4 | 5 | toolchain go1.24.5 6 | 7 | require ( 8 | github.com/cheggaaa/pb/v3 v3.1.7 9 | github.com/fatih/color v1.18.0 10 | github.com/go-flac/flacpicture v0.3.0 11 | github.com/go-flac/flacvorbis v0.2.0 12 | github.com/go-flac/go-flac v1.0.0 13 | github.com/spf13/cobra v1.10.1 14 | github.com/zmb3/spotify/v2 v2.4.3 15 | golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5 16 | golang.org/x/sync v0.3.0 17 | ) 18 | 19 | require ( 20 | github.com/delucks/go-subsonic v0.0.0-20240806025900-2a743ec36238 21 | github.com/hashicorp/go-version v1.7.0 22 | github.com/mattn/go-isatty v0.0.20 23 | ) 24 | 25 | require ( 26 | github.com/VividCortex/ewma v1.2.0 // indirect 27 | github.com/golang/protobuf v1.5.2 // indirect 28 | github.com/google/go-cmp v0.7.0 // indirect 29 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 30 | github.com/mattn/go-colorable v0.1.14 // indirect 31 | github.com/mattn/go-runewidth v0.0.16 // indirect 32 | github.com/rivo/uniseg v0.4.7 // indirect 33 | github.com/spf13/pflag v1.0.9 // indirect 34 | golang.org/x/net v0.23.0 // indirect 35 | golang.org/x/sys v0.30.0 // indirect 36 | google.golang.org/appengine v1.6.7 // indirect 37 | google.golang.org/protobuf v1.33.0 // indirect 38 | ) 39 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use a multi-stage build for a smaller final image 2 | 3 | # Stage 1: Build the Go application 4 | FROM golang:1.21-alpine AS builder 5 | 6 | # Set working directory 7 | WORKDIR /app 8 | 9 | # Copy go.mod and go.sum and download dependencies 10 | COPY go.mod . 11 | COPY go.sum . 12 | 13 | # Copy the rest of the application source code 14 | COPY . . 15 | 16 | # Download dependencies and tidy go.mod/go.sum 17 | RUN go mod tidy 18 | RUN go mod download 19 | 20 | # Build the application 21 | # CGO_ENABLED=0 is important for static linking, making the binary self-contained 22 | # -ldflags="-s -w" reduces the binary size by stripping debug information 23 | RUN CGO_ENABLED=0 go build -o dab-downloader -ldflags="-s -w" . 24 | 25 | # Stage 2: Create the final lean image 26 | FROM alpine:latest 27 | 28 | # Install ffmpeg for audio conversion 29 | RUN apk add --no-cache ffmpeg 30 | 31 | # Set working directory 32 | WORKDIR /app 33 | 34 | # Copy the built executable from the builder stage 35 | COPY --from=builder /app/dab-downloader . 36 | 37 | # Copy version.json from the builder stage 38 | COPY --from=builder /app/version/version.json version/ 39 | 40 | # Copy example-config.json to be used as a template 41 | COPY config/example-config.json config/ 42 | 43 | # Expose a volume for persistent data (config and downloads) 44 | VOLUME /app/config /app/music 45 | 46 | # Set the entrypoint to run the application 47 | ENTRYPOINT ["./dab-downloader"] 48 | -------------------------------------------------------------------------------- /ffmpeg.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "strings" 9 | ) 10 | 11 | // CheckFFmpeg checks if ffmpeg is installed and available in the system's PATH. 12 | func CheckFFmpeg() bool { 13 | _, err := exec.LookPath("ffmpeg") 14 | return err == nil 15 | } 16 | 17 | // ConvertTrack converts a track to the specified format using ffmpeg. 18 | func ConvertTrack(inputFile, format, bitrate string) (string, error) { 19 | outputFile := strings.TrimSuffix(inputFile, filepath.Ext(inputFile)) + "." + format 20 | 21 | var cmd *exec.Cmd 22 | switch format { 23 | case "mp3": 24 | cmd = exec.Command("ffmpeg", "-i", inputFile, "-b:a", bitrate+"k", "-vn", "-map_metadata", "0", outputFile) 25 | case "ogg": 26 | // For ogg, -q:a (quality) is often preferred over bitrate. 27 | // A mapping from bitrate to quality could be implemented if needed. 28 | // For now, using a high quality setting. 29 | cmd = exec.Command("ffmpeg", "-i", inputFile, "-c:a", "libvorbis", "-q:a", "8", "-vn", "-map_metadata", "0", outputFile) 30 | case "opus": 31 | cmd = exec.Command("ffmpeg", "-i", inputFile, "-c:a", "libopus", "-b:a", bitrate+"k", "-vn", "-map_metadata", "0", outputFile) 32 | default: 33 | return "", fmt.Errorf("unsupported format: %s", format) 34 | } 35 | 36 | output, err := cmd.CombinedOutput() 37 | if err != nil { 38 | return "", fmt.Errorf("failed to convert track: %w\nffmpeg output: %s", err, string(output)) 39 | } 40 | 41 | // Verify that the output file was created 42 | if _, err := os.Stat(outputFile); os.IsNotExist(err) { 43 | return "", fmt.Errorf("converted file not found after conversion") 44 | } 45 | 46 | return outputFile, nil 47 | } 48 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## 📋 Pull Request Description 2 | 3 | ### What does this PR do? 4 | 5 | 6 | ### Type of Change 7 | 8 | - [ ] 🐛 Bug fix (non-breaking change which fixes an issue) 9 | - [ ] ✨ New feature (non-breaking change which adds functionality) 10 | - [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected) 11 | - [ ] 📚 Documentation update 12 | - [ ] 🔧 Code refactoring (no functional changes) 13 | - [ ] ⚡ Performance improvement 14 | - [ ] 🧪 Test coverage improvement 15 | 16 | ### Related Issues 17 | 18 | Fixes #(issue_number) 19 | Relates to #(issue_number) 20 | 21 | ### 🧪 Testing 22 | 23 | - [ ] I have tested this change locally 24 | - [ ] I have added tests that prove my fix is effective or that my feature works 25 | - [ ] New and existing unit tests pass locally with my changes 26 | - [ ] I have tested edge cases 27 | 28 | **Test Commands:** 29 | ```bash 30 | # Commands you used to test this change 31 | ./dab-downloader test-command 32 | ``` 33 | 34 | ### 📝 Changes Made 35 | 36 | - 37 | - 38 | - 39 | 40 | ### 🔍 Screenshots/Output 41 | 42 | 43 | ### ✅ Checklist 44 | 45 | - [ ] My code follows the project's style guidelines 46 | - [ ] I have performed a self-review of my own code 47 | - [ ] I have commented my code, particularly in hard-to-understand areas 48 | - [ ] I have made corresponding changes to the documentation 49 | - [ ] My changes generate no new warnings 50 | - [ ] Any dependent changes have been merged and published 51 | 52 | ### 🤔 Questions for Reviewers 53 | 54 | 55 | ### 📄 Additional Notes 56 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.yml: -------------------------------------------------------------------------------- 1 | name: ❓ Question / Support 2 | description: Ask a question or get help with usage 3 | title: "[QUESTION] " 4 | labels: ["question", "support"] 5 | assignees: [] 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Have a question about using DAB Music Downloader? We're here to help! 11 | 12 | **Please check first:** 13 | - [README documentation](https://github.com/PrathxmOp/dab-downloader#readme) 14 | - [Troubleshooting section](https://github.com/PrathxmOp/dab-downloader#troubleshooting) 15 | - [Existing discussions](https://github.com/PrathxmOp/dab-downloader/discussions) 16 | 17 | - type: checkboxes 18 | id: preliminary-checks 19 | attributes: 20 | label: I have checked 21 | options: 22 | - label: README and documentation 23 | required: true 24 | - label: Existing issues and discussions 25 | required: true 26 | - label: Troubleshooting guide 27 | required: true 28 | 29 | - type: dropdown 30 | id: question-category 31 | attributes: 32 | label: Question Category 33 | description: What area is your question about? 34 | options: 35 | - Installation and setup 36 | - Basic usage and commands 37 | - Configuration and settings 38 | - Spotify integration 39 | - Navidrome integration 40 | - Docker usage 41 | - Troubleshooting specific errors 42 | - Performance and optimization 43 | - Legal and ethical usage 44 | - Other 45 | validations: 46 | required: true 47 | 48 | - type: textarea 49 | id: question 50 | attributes: 51 | label: Your Question 52 | description: Ask your question in detail 53 | placeholder: "How do I configure Spotify integration? What credentials do I need?" 54 | validations: 55 | required: true 56 | 57 | - type: textarea 58 | id: what-tried 59 | attributes: 60 | label: What Have You Tried? 61 | description: What steps have you already taken to solve this? 62 | placeholder: "I tried following the README but got stuck at..." 63 | 64 | - type: textarea 65 | id: context 66 | attributes: 67 | label: Additional Context 68 | description: Your setup, environment, or any other relevant details 69 | placeholder: | 70 | - OS: Windows 11 71 | - Installation method: Pre-built binary 72 | - What I'm trying to achieve: ... 73 | 74 | - type: input 75 | id: version 76 | attributes: 77 | label: Version 78 | description: What version are you using? 79 | placeholder: "v3.0.1" -------------------------------------------------------------------------------- /search.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | func handleSearch(ctx context.Context, api *DabAPI, query string, searchType string, debug bool, auto bool) ([]interface{}, []string, error) { 9 | colorInfo.Printf("🔎 Searching for '%s' (type: %s)...", query, searchType) 10 | 11 | results, err := api.Search(ctx, query, searchType, 10, debug) 12 | if err != nil { 13 | return nil, nil, err 14 | } 15 | 16 | totalResults := len(results.Artists) + len(results.Albums) + len(results.Tracks) 17 | if totalResults == 0 { 18 | colorWarning.Println("No results found.") 19 | return nil, nil, nil 20 | } 21 | 22 | if auto { 23 | var selectedItems []interface{} 24 | var itemTypes []string 25 | if len(results.Artists) > 0 { 26 | selectedItems = append(selectedItems, results.Artists[0]) 27 | itemTypes = append(itemTypes, "artist") 28 | } else if len(results.Albums) > 0 { 29 | selectedItems = append(selectedItems, results.Albums[0]) 30 | itemTypes = append(itemTypes, "album") 31 | } else if len(results.Tracks) > 0 { 32 | selectedItems = append(selectedItems, results.Tracks[0]) 33 | itemTypes = append(itemTypes, "track") 34 | } 35 | return selectedItems, itemTypes, nil 36 | } 37 | 38 | colorInfo.Printf("Found %d results:\n", totalResults) 39 | 40 | // Display results 41 | counter := 1 42 | if len(results.Artists) > 0 { 43 | colorInfo.Println("\n--- Artists ---") 44 | for _, artist := range results.Artists { 45 | fmt.Printf("%d. %s\n", counter, artist.Name) 46 | counter++ 47 | } 48 | } 49 | if len(results.Albums) > 0 { 50 | colorInfo.Println("\n--- Albums ---") 51 | for _, album := range results.Albums { 52 | fmt.Printf("%d. %s - %s\n", counter, album.Title, album.Artist) 53 | counter++ 54 | } 55 | } 56 | if len(results.Tracks) > 0 { 57 | colorInfo.Println("\n--- Tracks ---") 58 | for _, track := range results.Tracks { 59 | fmt.Printf("%d. %s - %s (%s)\n", counter, track.Title, track.Artist, track.Album) 60 | counter++ 61 | } 62 | } 63 | 64 | // Prompt for selection 65 | selectionStr := GetUserInput("\nEnter numbers to download (e.g., '1,3,5-7' or 'q' to quit)", "") 66 | if selectionStr == "q" || selectionStr == "" { 67 | return nil, nil, nil 68 | } 69 | 70 | selectedIndices, err := ParseSelectionInput(selectionStr, totalResults) 71 | if err != nil { 72 | return nil, nil, fmt.Errorf("invalid selection: %w", err) 73 | } 74 | 75 | var selectedItems []interface{} 76 | var itemTypes []string 77 | 78 | for _, selectedIndex := range selectedIndices { 79 | index := selectedIndex - 1 80 | if index < len(results.Artists) { 81 | selectedItems = append(selectedItems, results.Artists[index]) 82 | itemTypes = append(itemTypes, "artist") 83 | } else { 84 | index -= len(results.Artists) 85 | if index < len(results.Albums) { 86 | selectedItems = append(selectedItems, results.Albums[index]) 87 | itemTypes = append(itemTypes, "album") 88 | } else { 89 | index -= len(results.Albums) 90 | if index < len(results.Tracks) { 91 | selectedItems = append(selectedItems, results.Tracks[index]) 92 | itemTypes = append(itemTypes, "track") 93 | } else { 94 | return nil, nil, fmt.Errorf("invalid index %d after parsing", selectedIndex) 95 | } 96 | } 97 | } 98 | } 99 | 100 | return selectedItems, itemTypes, nil 101 | } -------------------------------------------------------------------------------- /retry.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "math/rand" 7 | "net/http" 8 | "time" 9 | ) 10 | 11 | // HTTPError represents an HTTP error with status code 12 | type HTTPError struct { 13 | StatusCode int 14 | Status string 15 | Message string 16 | } 17 | 18 | func (e *HTTPError) Error() string { 19 | return fmt.Sprintf("HTTP %d: %s - %s", e.StatusCode, e.Status, e.Message) 20 | } 21 | 22 | // IsRetryableHTTPError checks if an HTTP error should be retried 23 | func IsRetryableHTTPError(err error) bool { 24 | // Unwrap the error if it's wrapped 25 | for err != nil { 26 | if httpErr, ok := err.(*HTTPError); ok { 27 | switch httpErr.StatusCode { 28 | case http.StatusServiceUnavailable, // 503 29 | http.StatusTooManyRequests, // 429 30 | http.StatusBadGateway, // 502 31 | http.StatusGatewayTimeout: // 504 32 | return true 33 | } 34 | } 35 | // Try to unwrap the error further 36 | if unwrapped, ok := err.(interface{ Unwrap() error }); ok { 37 | err = unwrapped.Unwrap() 38 | } else { 39 | break 40 | } 41 | } 42 | return false 43 | } 44 | 45 | // RetryWithBackoff retries the given function with exponential backoff. 46 | func RetryWithBackoff(maxRetries int, initialDelaySec int, fn func() error) error { 47 | var err error 48 | for attempt := 0; attempt < maxRetries; attempt++ { 49 | err = fn() 50 | if err == nil { 51 | return nil 52 | } 53 | 54 | // Calculate delay with exponential backoff and some jitter 55 | delay := time.Duration(initialDelaySec) * time.Second * (1 << attempt) 56 | jitter := time.Duration(rand.Intn(100)) * time.Millisecond 57 | time.Sleep(delay + jitter) 58 | } 59 | return fmt.Errorf("failed after %d attempts: %w", maxRetries, err) 60 | } 61 | 62 | // RetryWithBackoffForHTTP retries HTTP requests with smart error handling 63 | func RetryWithBackoffForHTTP(maxRetries int, initialDelay time.Duration, maxDelay time.Duration, fn func() error) error { 64 | return RetryWithBackoffForHTTPWithDebug(maxRetries, initialDelay, maxDelay, fn, false) 65 | } 66 | 67 | // RetryWithBackoffForHTTPWithDebug retries HTTP requests with smart error handling and optional debug logging 68 | func RetryWithBackoffForHTTPWithDebug(maxRetries int, initialDelay time.Duration, maxDelay time.Duration, fn func() error, debug bool) error { 69 | var lastErr error 70 | 71 | if maxRetries == 0 { // If no retries, just execute once 72 | return fn() 73 | } 74 | 75 | for attempt := 0; attempt < maxRetries; attempt++ { lastErr = fn() 76 | if lastErr == nil { 77 | return nil 78 | } 79 | 80 | // Check if this is a retryable HTTP error 81 | if !IsRetryableHTTPError(lastErr) { 82 | return lastErr // Don't retry non-retryable errors 83 | } 84 | 85 | if attempt == maxRetries-1 { 86 | break // Don't sleep on the last attempt 87 | } 88 | 89 | // Calculate delay with exponential backoff and jitter 90 | delay := initialDelay * time.Duration(1< maxDelay { 92 | delay = maxDelay 93 | } 94 | 95 | // Add jitter (±25% of delay) 96 | jitter := time.Duration(rand.Int63n(int64(delay/2))) - delay/4 97 | finalDelay := delay + jitter 98 | 99 | if finalDelay < 0 { 100 | finalDelay = delay 101 | } 102 | 103 | // Only log retry messages in debug mode 104 | if debug { 105 | log.Printf("HTTP request failed (attempt %d/%d): %v. Retrying in %v", 106 | attempt+1, maxRetries, lastErr, finalDelay) 107 | } 108 | 109 | time.Sleep(finalDelay) 110 | } 111 | 112 | return fmt.Errorf("failed after %d attempts: %w", maxRetries, lastErr) 113 | } 114 | -------------------------------------------------------------------------------- /spotify.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "strings" 8 | 9 | "github.com/zmb3/spotify/v2" 10 | spotifyauth "github.com/zmb3/spotify/v2/auth" 11 | "golang.org/x/oauth2/clientcredentials" 12 | ) 13 | 14 | // SpotifyTrack represents a track from Spotify 15 | type SpotifyTrack struct { 16 | Name string 17 | Artist string 18 | AlbumName string 19 | AlbumArtist string 20 | } 21 | 22 | // Authenticate authenticates the client with the spotify api 23 | func (s *SpotifyClient) Authenticate() error { 24 | ctx := context.Background() 25 | config := &clientcredentials.Config{ 26 | ClientID: s.ID, 27 | ClientSecret: s.Secret, 28 | TokenURL: spotifyauth.TokenURL, 29 | } 30 | token, err := config.Token(ctx) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | httpClient := spotifyauth.New().Client(ctx, token) 36 | s.client = spotify.New(httpClient) 37 | return nil 38 | } 39 | 40 | // GetPlaylistTracks gets the tracks from a spotify playlist 41 | func (s *SpotifyClient) GetPlaylistTracks(playlistURL string) ([]SpotifyTrack, string, error) { // Updated signature 42 | parts := strings.Split(playlistURL, "/") 43 | if len(parts) < 5 { 44 | return nil, "", fmt.Errorf("invalid playlist URL") 45 | } 46 | playlistIDStr := strings.Split(parts[4], "?")[0] 47 | playlistID := spotify.ID(playlistIDStr) 48 | 49 | log.Printf("Fetching tracks from playlist: %s", playlistID) 50 | 51 | playlist, err := s.client.GetPlaylist(context.Background(), playlistID) 52 | if err != nil { 53 | return nil, "", err // Updated return 54 | } 55 | log.Printf("Spotify Playlist Name: %s", playlist.Name) 56 | 57 | var tracks []SpotifyTrack // Updated type 58 | for { 59 | for _, item := range playlist.Tracks.Tracks { 60 | if item.Track.Album.Name == "" { 61 | continue // Skip tracks with no album info 62 | } 63 | trackName := item.Track.Name 64 | artistName := item.Track.Artists[0].Name 65 | albumName := item.Track.Album.Name 66 | albumArtist := item.Track.Album.Artists[0].Name 67 | tracks = append(tracks, SpotifyTrack{ 68 | Name: trackName, 69 | Artist: artistName, 70 | AlbumName: albumName, 71 | AlbumArtist: albumArtist, 72 | }) // Updated append 73 | } 74 | 75 | err = s.client.NextPage(context.Background(), &playlist.Tracks) 76 | if err == spotify.ErrNoMorePages { 77 | break 78 | } 79 | if err != nil { 80 | return nil, "", err 81 | } 82 | } 83 | 84 | return tracks, playlist.Name, nil // Updated return to include playlist.Name 85 | } 86 | 87 | // GetAlbumTracks gets the tracks from a spotify album 88 | func (s *SpotifyClient) GetAlbumTracks(albumURL string) ([]SpotifyTrack, string, error) { 89 | parts := strings.Split(albumURL, "/") 90 | if len(parts) < 5 || parts[3] != "album" { 91 | return nil, "", fmt.Errorf("invalid album URL") 92 | } 93 | albumIDStr := strings.Split(parts[4], "?")[0] 94 | albumID := spotify.ID(albumIDStr) 95 | 96 | log.Printf("Fetching tracks from album: %s", albumID) 97 | 98 | album, err := s.client.GetAlbum(context.Background(), albumID) 99 | if err != nil { 100 | return nil, "", err 101 | } 102 | log.Printf("Spotify Album Name: %s", album.Name) 103 | 104 | var tracks []SpotifyTrack 105 | for _, track := range album.Tracks.Tracks { 106 | trackName := track.Name 107 | artistName := track.Artists[0].Name 108 | tracks = append(tracks, SpotifyTrack{ 109 | Name: trackName, 110 | Artist: artistName, 111 | AlbumName: album.Name, 112 | AlbumArtist: album.Artists[0].Name, 113 | }) 114 | } 115 | 116 | return tracks, album.Name, nil 117 | } 118 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | name: Build and Release 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write # Grant write permissions for GITHUB_TOKEN to create releases and upload assets 14 | outputs: 15 | tag_name: ${{ env.VERSION }} # Output the version from version.json 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Get version from version.json 20 | id: get_version 21 | run: | 22 | VERSION=$(cat version/version.json | jq -r '.version') 23 | echo "VERSION=$VERSION" >> $GITHUB_ENV 24 | 25 | - name: Set up Go 26 | uses: actions/setup-go@v4 27 | with: 28 | go-version: '1.x' 29 | 30 | - name: Download Go Modules 31 | run: | 32 | go mod tidy 33 | go mod download 34 | 35 | - name: Build for Linux 36 | run: | 37 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o dab-downloader-linux-amd64 38 | - name: Build for Windows 39 | run: | 40 | CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o dab-downloader-windows-amd64.exe 41 | - name: Build for macOS 42 | run: | 43 | CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o dab-downloader-macos-amd64 44 | 45 | - name: Build for Linux (ARM64) 46 | run: | 47 | CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o dab-downloader-linux-arm64 48 | 49 | - name: Create and Push Git Tag 50 | run: | 51 | git config user.name github-actions 52 | git config user.email github-actions@github.com 53 | git tag ${{ env.VERSION }} 54 | git push origin ${{ env.VERSION }} 55 | 56 | - name: Generate Changelog 57 | id: generate_changelog 58 | run: | 59 | CHANGELOG=$(git log $(git describe --tags --abbrev=0)..HEAD --pretty=format:"- %s (%h)") 60 | echo "CHANGELOG<> $GITHUB_ENV 61 | echo "$CHANGELOG" >> $GITHUB_ENV 62 | echo "EOF" >> $GITHUB_ENV 63 | 64 | - name: Create GitHub Release 65 | id: create_release 66 | uses: softprops/action-gh-release@v1 67 | env: 68 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 69 | with: 70 | tag_name: ${{ env.VERSION }} 71 | name: Release ${{ env.VERSION }} 72 | body: ${{ env.CHANGELOG }} 73 | draft: false 74 | prerelease: false 75 | files: | 76 | ./dab-downloader-linux-amd64 77 | ./dab-downloader-windows-amd64.exe 78 | ./dab-downloader-macos-amd64 79 | ./dab-downloader-linux-arm64 80 | 81 | - name: Verify Release Creation 82 | run: | 83 | gh auth setup-git 84 | gh release list 85 | env: 86 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 87 | 88 | build-and-push-docker: 89 | name: Build and Push Docker Image 90 | runs-on: ubuntu-latest 91 | needs: build # This job depends on the 'build' job to ensure TAG_NAME is available 92 | permissions: 93 | contents: read 94 | packages: write # To push to GitHub Packages, or adjust for Docker Hub 95 | 96 | steps: 97 | - uses: actions/checkout@v3 98 | 99 | - name: Log in to Docker Hub 100 | uses: docker/login-action@v2 101 | with: 102 | username: ${{ secrets.DOCKER_USERNAME }} 103 | password: ${{ secrets.DOCKER_PASSWORD }} 104 | 105 | - name: Build and push Docker image 106 | uses: docker/build-push-action@v4 107 | with: 108 | context: . 109 | push: true 110 | tags: | 111 | prathxm/dab-downloader:${{ needs.build.outputs.tag_name }} 112 | prathxm/dab-downloader:latest 113 | -------------------------------------------------------------------------------- /debug.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | ) 9 | 10 | // TestArtistEndpoints tests different possible artist endpoint formats 11 | func (api *DabAPI) TestArtistEndpoints(ctx context.Context, artistID string) { 12 | colorInfo.Printf("🔍 Testing different artist endpoint formats for ID: %s\n", artistID) 13 | 14 | // Test different endpoint variations 15 | endpoints := []struct { 16 | path string 17 | params []QueryParam 18 | description string 19 | }{ 20 | {"discography", []QueryParam{{Name: "artistId", Value: artistID}}, "Correct endpoint (discography?artistId=)"}, 21 | {"api/discography", []QueryParam{{Name: "artistId", Value: artistID}}, "With api prefix (api/discography?artistId=)"}, 22 | {"discography", []QueryParam{{Name: "id", Value: artistID}}, "Alternative param (discography?id=)"}, 23 | {"api/artist", []QueryParam{{Name: "artistId", Value: artistID}}, "Old format (api/artist?artistId=)"}, 24 | {"api/artist", []QueryParam{{Name: "id", Value: artistID}}, "Alternative param (api/artist?id=)"}, 25 | {"api/artists", []QueryParam{{Name: "artistId", Value: artistID}}, "Plural endpoint (api/artists?artistId=)"}, 26 | } 27 | 28 | for i, endpoint := range endpoints { 29 | fmt.Printf("\n🧪 Test %d: %s\n", i+1, endpoint.description) 30 | 31 | resp, err := api.Request(ctx, endpoint.path, true, endpoint.params) 32 | if err != nil { 33 | colorError.Printf(" ❌ Failed: %v\n", err) 34 | continue 35 | } 36 | 37 | body, err := io.ReadAll(resp.Body) 38 | resp.Body.Close() 39 | 40 | if err != nil { 41 | colorError.Printf(" ❌ Failed to read body: %v\n", err) 42 | continue 43 | } 44 | 45 | if resp.StatusCode == http.StatusOK { 46 | colorSuccess.Printf(" ✅ SUCCESS! Status: %d, Body length: %d bytes\n", resp.StatusCode, len(body)) 47 | colorSuccess.Printf(" Response preview: %.200s...\n", string(body)) 48 | } else { 49 | colorWarning.Printf(" ⚠️ Status: %d, Body: %s\n", resp.StatusCode, string(body)) 50 | } 51 | } 52 | } 53 | 54 | // TestAPIAvailability tests basic API connectivity 55 | func (api *DabAPI) TestAPIAvailability(ctx context.Context) { 56 | colorInfo.Println("🌐 Testing basic API connectivity...") 57 | 58 | // Try a simple request to the base API 59 | resp, err := api.Request(ctx, "", true, nil) 60 | if err != nil { 61 | colorError.Printf("❌ Base API test failed: %v\n", err) 62 | return 63 | } 64 | defer resp.Body.Close() 65 | 66 | body, _ := io.ReadAll(resp.Body) 67 | colorSuccess.Printf("✅ Base API accessible. Status: %d, Response: %.200s\n", resp.StatusCode, string(body)) 68 | } 69 | 70 | // DebugArtistID performs comprehensive debugging for an artist ID 71 | func (api *DabAPI) DebugArtistID(ctx context.Context, artistID string) { 72 | colorInfo.Printf("🐛 Starting comprehensive debug for artist ID: %s\n", artistID) 73 | 74 | // Test basic connectivity 75 | api.TestAPIAvailability(ctx) 76 | 77 | // Test different endpoint formats 78 | api.TestArtistEndpoints(ctx, artistID) 79 | 80 | // Check if it might be an album or track ID instead 81 | colorInfo.Printf("\n🔄 Testing if ID might be for album or track instead...\n") 82 | 83 | // Test as album ID 84 | resp, err := api.Request(ctx, "api/album", true, []QueryParam{{Name: "albumId", Value: artistID}}) 85 | if err == nil { 86 | body, _ := io.ReadAll(resp.Body) 87 | resp.Body.Close() 88 | if resp.StatusCode == http.StatusOK { 89 | colorWarning.Printf("⚠️ ID works as ALBUM ID! You might have provided an album ID instead of artist ID\n") 90 | colorWarning.Printf(" Album response preview: %.200s...\n", string(body)) 91 | } 92 | } 93 | 94 | // Test as track ID 95 | resp, err = api.Request(ctx, "api/track", true, []QueryParam{{Name: "trackId", Value: artistID}}) 96 | if err == nil { 97 | body, _ := io.ReadAll(resp.Body) 98 | resp.Body.Close() 99 | if resp.StatusCode == http.StatusOK { 100 | colorWarning.Printf("⚠️ ID works as TRACK ID! You might have provided a track ID instead of artist ID\n") 101 | colorWarning.Printf(" Track response preview: %.200s...\n", string(body)) 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 💡 Feature Request 2 | description: Suggest a new feature or enhancement 3 | title: "[FEATURE] " 4 | labels: ["enhancement", "needs-discussion"] 5 | assignees: [] 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Thank you for suggesting a new feature! Please provide detailed information to help us understand and evaluate your request. 11 | 12 | - type: checkboxes 13 | id: preliminary-checks 14 | attributes: 15 | label: Preliminary Checks 16 | description: Please confirm before submitting 17 | options: 18 | - label: I have searched existing issues and discussions for similar requests 19 | required: true 20 | - label: This feature would benefit multiple users, not just my specific use case 21 | required: true 22 | 23 | - type: dropdown 24 | id: feature-category 25 | attributes: 26 | label: Feature Category 27 | description: What type of feature is this? 28 | options: 29 | - Download functionality 30 | - Search and discovery 31 | - Metadata handling 32 | - User interface/experience 33 | - Performance improvement 34 | - Integration (Spotify, Navidrome, etc.) 35 | - Configuration/settings 36 | - Quality of life improvement 37 | - Other 38 | validations: 39 | required: true 40 | 41 | - type: textarea 42 | id: feature-summary 43 | attributes: 44 | label: Feature Summary 45 | description: A brief, clear summary of the feature you'd like to see 46 | placeholder: "Add support for downloading from YouTube Music playlists" 47 | validations: 48 | required: true 49 | 50 | - type: textarea 51 | id: problem-statement 52 | attributes: 53 | label: Problem Statement 54 | description: What problem does this feature solve? What's the current limitation? 55 | placeholder: "Currently, users can only import from Spotify playlists, but many users also have YouTube Music collections..." 56 | validations: 57 | required: true 58 | 59 | - type: textarea 60 | id: proposed-solution 61 | attributes: 62 | label: Proposed Solution 63 | description: Describe your ideal solution in detail 64 | placeholder: "Add a new command like `./dab-downloader youtube ` that..." 65 | validations: 66 | required: true 67 | 68 | - type: textarea 69 | id: alternative-solutions 70 | attributes: 71 | label: Alternative Solutions 72 | description: Have you considered any alternative approaches? 73 | placeholder: "Alternatively, this could be implemented as..." 74 | 75 | - type: textarea 76 | id: use-cases 77 | attributes: 78 | label: Use Cases 79 | description: Provide specific examples of how this feature would be used 80 | placeholder: | 81 | 1. User wants to migrate their YouTube Music library 82 | 2. DJ needs to quickly download tracks from a collaborative playlist 83 | 3. ... 84 | value: | 85 | 1. 86 | 2. 87 | 3. 88 | 89 | - type: dropdown 90 | id: priority 91 | attributes: 92 | label: Priority Level 93 | description: How important is this feature to you? 94 | options: 95 | - Low - Nice to have 96 | - Medium - Would improve my workflow 97 | - High - Essential for my use case 98 | - Critical - Blocking my usage 99 | validations: 100 | required: true 101 | 102 | - type: textarea 103 | id: additional-context 104 | attributes: 105 | label: Additional Context 106 | description: Any other relevant information, mockups, or examples 107 | placeholder: "Links to similar features in other tools, mockups, technical considerations..." 108 | 109 | - type: checkboxes 110 | id: contribution 111 | attributes: 112 | label: Contribution 113 | description: Would you be willing to help implement this feature? 114 | options: 115 | - label: I would be interested in contributing code for this feature 116 | - label: I can help with testing this feature 117 | - label: I can help with documentation for this feature 118 | - label: I can provide domain expertise or guidance -------------------------------------------------------------------------------- /updater.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "fmt" // Added this import 7 | "net/http" 8 | "os" 9 | "os/exec" 10 | "runtime" 11 | "strings" 12 | 13 | version "github.com/hashicorp/go-version" // Added this import 14 | ) 15 | 16 | 17 | 18 | // CheckForUpdates checks for a newer version on GitHub 19 | func CheckForUpdates(config *Config, currentVersion string) { 20 | if config.DisableUpdateCheck { 21 | colorInfo.Println("Skipping update check as DisableUpdateCheck is enabled in config.") 22 | return 23 | } 24 | 25 | // Fetch remote version.json 26 | repoURL := "PrathxmOp/dab-downloader" // Default value 27 | if config.UpdateRepo != "" { 28 | repoURL = config.UpdateRepo 29 | } 30 | rawURL := fmt.Sprintf("https://raw.githubusercontent.com/%s/main/version/version.json", repoURL) 31 | resp, err := http.Get(rawURL) 32 | if err != nil { 33 | colorError.Printf("Error checking for updates: %v\n", err) 34 | return 35 | } 36 | defer resp.Body.Close() 37 | 38 | if resp.StatusCode != http.StatusOK { 39 | colorError.Printf("Error checking for updates: GitHub API returned status %d\n", resp.StatusCode) 40 | return 41 | } 42 | 43 | var remoteVersionInfo VersionInfo 44 | if err := json.NewDecoder(resp.Body).Decode(&remoteVersionInfo); err != nil { 45 | colorError.Printf("Error decoding remote version.json: %v\n", err) 46 | return 47 | } 48 | 49 | latestVersion := remoteVersionInfo.Version 50 | 51 | 52 | if isNewerVersion(latestVersion, currentVersion) { 53 | colorError.Printf("🚨 You are using an outdated version (%s) of dab-downloader! A new version (%s) is available.\n", currentVersion, latestVersion) 54 | colorPrompt.Print("Would you like to update now? (Y/n): ") 55 | reader := bufio.NewReader(os.Stdin) 56 | input, _ := reader.ReadString('\n') 57 | input = strings.ToLower(strings.TrimSpace(input)) 58 | 59 | if input == "y" || input == "" { 60 | colorInfo.Println("Attempting to open the Update Guide in your browser...") 61 | updateURL := "https://github.com/PrathxmOp/dab-downloader/#option-1-using-auto-dl.sh-script-recommended" 62 | if err := openBrowser(updateURL, config); err != nil { 63 | colorWarning.Printf("Failed to open browser automatically: %v\n", err) 64 | colorInfo.Println("Please refer to the 'Update Guide' section in the README for detailed instructions:") 65 | colorInfo.Println("https://github.com/PrathxmOp/dab-downloader/#update-guide") 66 | } 67 | } else { 68 | colorInfo.Println("You can update later by referring to the 'Update Guide' in the README.") 69 | } 70 | } else { 71 | colorSuccess.Println("✅ You are running the latest version of dab-downloader.") 72 | } 73 | } 74 | 75 | func openBrowser(url string, config *Config) error { 76 | if config.IsDockerContainer { 77 | colorInfo.Printf("Running in Docker, please open the update guide manually: %s\n", url) 78 | return nil 79 | } 80 | 81 | var cmd string 82 | var args []string 83 | 84 | 85 | switch runtime.GOOS { 86 | case "windows": 87 | cmd = "cmd" 88 | args = []string{"/c", "start", url} 89 | case "darwin": 90 | cmd = "open" 91 | args = []string{url} 92 | default: // "linux", "freebsd", "openbsd", "netbsd" 93 | cmd = "xdg-open" 94 | args = []string{url} 95 | } 96 | 97 | return exec.Command(cmd, args...).Start() 98 | } 99 | 100 | // isNewerVersion compares two versions using semantic versioning 101 | func isNewerVersion(latest, current string) bool { 102 | vLatest, err := version.NewVersion(latest) 103 | if err != nil { 104 | colorWarning.Printf("⚠️ Error parsing latest version '%s': %v\n", latest, err) 105 | return false // Cannot determine if newer, assume not 106 | } 107 | 108 | vCurrent, err := version.NewVersion(current) 109 | if err != nil { 110 | colorWarning.Printf("⚠️ Error parsing current version '%s': %v\n", current, err) 111 | return false // Cannot determine if newer, assume not 112 | } 113 | 114 | return vLatest.GreaterThan(vCurrent) 115 | } 116 | 117 | // extractDateFromVersion is no longer needed with semantic versioning 118 | // func extractDateFromVersion(version string) string { 119 | // if strings.HasPrefix(version, "v") && strings.Contains(version, "-") { 120 | // parts := strings.Split(version[1:], "-") // Remove 'v' and split by '-' 121 | // if len(parts) > 0 && len(parts[0]) == 8 { // Check if it looks like YYYYMMDD 122 | // return parts[0] 123 | // } 124 | // } 125 | // return "" 126 | // } 127 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug Report 2 | description: Report a bug or unexpected behavior 3 | title: "[BUG] " 4 | labels: ["bug", "needs-triage"] 5 | assignees: [] 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Thanks for taking the time to fill out this bug report! Please provide as much detail as possible to help us resolve the issue quickly. 11 | 12 | - type: checkboxes 13 | id: preliminary-checks 14 | attributes: 15 | label: Preliminary Checks 16 | description: Please confirm you've completed these steps before submitting 17 | options: 18 | - label: I have searched existing issues to avoid duplicates 19 | required: true 20 | - label: I am using the latest version of DAB Music Downloader 21 | required: true 22 | - label: I have read the troubleshooting section in the README 23 | required: true 24 | 25 | - type: dropdown 26 | id: installation-method 27 | attributes: 28 | label: Installation Method 29 | description: How did you install DAB Music Downloader? 30 | options: 31 | - Pre-built binary (GitHub Releases) 32 | - Built from source 33 | - Docker/Docker Compose 34 | - Other (please specify in description) 35 | validations: 36 | required: true 37 | 38 | - type: input 39 | id: version 40 | attributes: 41 | label: Version 42 | description: What version are you running? (Use `--version` if available) 43 | placeholder: "e.g., v3.0.1 or commit hash" 44 | validations: 45 | required: true 46 | 47 | - type: dropdown 48 | id: operating-system 49 | attributes: 50 | label: Operating System 51 | description: What OS are you using? 52 | options: 53 | - Windows 54 | - macOS 55 | - Linux (Ubuntu/Debian) 56 | - Linux (Arch/Manjaro) 57 | - Linux (RHEL/CentOS/Fedora) 58 | - Linux (Other) 59 | - Docker Container 60 | validations: 61 | required: true 62 | 63 | - type: textarea 64 | id: bug-description 65 | attributes: 66 | label: Bug Description 67 | description: A clear and concise description of what the bug is 68 | placeholder: Describe what happened vs what you expected to happen 69 | validations: 70 | required: true 71 | 72 | - type: textarea 73 | id: steps-to-reproduce 74 | attributes: 75 | label: Steps to Reproduce 76 | description: Detailed steps to reproduce the behavior 77 | placeholder: | 78 | 1. Run command '...' 79 | 2. Enter input '...' 80 | 3. See error 81 | value: | 82 | 1. 83 | 2. 84 | 3. 85 | validations: 86 | required: true 87 | 88 | - type: textarea 89 | id: expected-behavior 90 | attributes: 91 | label: Expected Behavior 92 | description: What should have happened instead? 93 | placeholder: A clear description of what you expected to happen 94 | validations: 95 | required: true 96 | 97 | - type: textarea 98 | id: error-output 99 | attributes: 100 | label: Error Output/Logs 101 | description: If applicable, paste any error messages or log output 102 | placeholder: Paste error messages, stack traces, or log output here 103 | render: shell 104 | 105 | - type: textarea 106 | id: command-used 107 | attributes: 108 | label: Command Used 109 | description: The exact command that triggered the issue 110 | placeholder: "./dab-downloader album 123456" 111 | render: shell 112 | 113 | - type: textarea 114 | id: config-file 115 | attributes: 116 | label: Configuration 117 | description: Your config.json content (remove sensitive information like API keys) 118 | placeholder: | 119 | { 120 | "APIURL": "https://dab.yeet.su", 121 | "DownloadLocation": "/home/user/Music", 122 | "Parallelism": 5 123 | } 124 | render: json 125 | 126 | - type: textarea 127 | id: additional-context 128 | attributes: 129 | label: Additional Context 130 | description: Any other context about the problem (screenshots, network conditions, etc.) 131 | placeholder: Add any other context, screenshots, or relevant information 132 | 133 | - type: checkboxes 134 | id: terms 135 | attributes: 136 | label: Acknowledgments 137 | options: 138 | - label: I understand this is for educational purposes only 139 | required: true 140 | - label: I am reporting this issue in good faith to help improve the software 141 | required: true -------------------------------------------------------------------------------- /types.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "time" 7 | ) 8 | 9 | // Add these constants to types.go or create constants.go 10 | const ( 11 | requestTimeout = 10 * time.Minute 12 | userAgent = "DAB-Downloader/2.0" 13 | defaultMaxRetries = 3 14 | ) 15 | 16 | // Configuration structure 17 | type Config struct { 18 | APIURL string 19 | DownloadLocation string 20 | Parallelism int 21 | SpotifyClientID string 22 | SpotifyClientSecret string 23 | NavidromeURL string 24 | NavidromeUsername string 25 | NavidromePassword string 26 | Format string 27 | Bitrate string 28 | SaveAlbumArt bool 29 | DisableUpdateCheck bool `json:"DisableUpdateCheck"` 30 | IsDockerContainer bool `json:"-"` // Not saved to config.json 31 | UpdateRepo string `json:"UpdateRepo"` 32 | NamingMasks NamingOptions `json:"naming"` 33 | VerifyDownloads bool `json:"VerifyDownloads"` // Enable/disable download verification 34 | MaxRetryAttempts int `json:"MaxRetryAttempts"` // Configurable retry attempts 35 | WarningBehavior string `json:"WarningBehavior"` // "immediate", "summary", or "silent" 36 | } 37 | 38 | // NamingOptions defines the configurable naming masks 39 | type NamingOptions struct { 40 | AlbumFolderMask string `json:"album_folder_mask"` 41 | EpFolderMask string `json:"ep_folder_mask"` 42 | SingleFolderMask string `json:"single_folder_mask"` 43 | FileMask string `json:"file_mask"` 44 | } 45 | 46 | // VersionInfo represents the structure of our version.json file 47 | type VersionInfo struct { 48 | Version string `json:"version"` 49 | } 50 | 51 | 52 | 53 | // Music data structures 54 | type Track struct { 55 | ID interface{} `json:"id"` 56 | Title string `json:"title"` 57 | Artist string `json:"artist"` 58 | ArtistId interface{} `json:"artistId"` // Added ArtistId field 59 | Cover string `json:"albumCover"` 60 | ReleaseDate string `json:"releaseDate"` 61 | Duration int `json:"duration"` 62 | Album string `json:"album,omitempty"` 63 | AlbumArtist string `json:"albumArtist,omitempty"` 64 | Genre string `json:"genre,omitempty"` 65 | TrackNumber int `json:"trackNumber,omitempty"` 66 | DiscNumber int `json:"discNumber,omitempty"` 67 | Composer string `json:"composer,omitempty"` 68 | Producer string `json:"producer,omitempty"` 69 | Year string `json:"year,omitempty"` 70 | ISRC string `json:"isrc,omitempty"` 71 | Copyright string `json:"copyright,omitempty"` 72 | AlbumID string `json:"albumId"` // Added AlbumID field 73 | MusicBrainzID string `json:"musicbrainzId,omitempty"` // MusicBrainz ID for the track 74 | } 75 | 76 | type Artist struct { 77 | ID interface{} `json:"id"` // Changed to interface{} to handle both string and number 78 | Name string `json:"name"` 79 | Picture string `json:"picture"` 80 | Albums []Album `json:"albums,omitempty"` 81 | Tracks []Track `json:"tracks,omitempty"` 82 | Bio string `json:"bio,omitempty"` 83 | Country string `json:"country,omitempty"` 84 | Followers int `json:"followers,omitempty"` 85 | } 86 | 87 | type Album struct { 88 | ID string `json:"id"` 89 | Title string `json:"title"` 90 | Artist string `json:"artist"` 91 | Cover string `json:"cover"` 92 | ReleaseDate string `json:"releaseDate"` 93 | Tracks []Track `json:"tracks"` 94 | Genre string `json:"genre,omitempty"` 95 | Type string `json:"type,omitempty"` // "album", "ep", "single", etc. 96 | Label interface{} `json:"label,omitempty"` 97 | UPC string `json:"upc,omitempty"` 98 | Copyright string `json:"copyright,omitempty"` 99 | Year string `json:"year,omitempty"` 100 | TotalTracks int `json:"totalTracks,omitempty"` 101 | TotalDiscs int `json:"totalDiscs,omitempty"` 102 | MusicBrainzID string `json:"musicbrainzId,omitempty"` // MusicBrainz ID for the album 103 | } 104 | 105 | // API response structures 106 | type ArtistResponse struct { 107 | Artist Artist `json:"artist"` 108 | } 109 | 110 | type AlbumResponse struct { 111 | Album Album `json:"album"` 112 | } 113 | 114 | type TrackResponse struct { 115 | Track Track `json:"track"` 116 | } 117 | 118 | type StreamURL struct { 119 | URL string `json:"url"` 120 | } 121 | 122 | type ArtistSearchResponse struct { 123 | Results []Artist `json:"results"` 124 | } 125 | 126 | type AlbumSearchResponse struct { 127 | Results []Album `json:"results"` 128 | } 129 | 130 | type TrackSearchResponse struct { 131 | Results []Track `json:"results"` 132 | } 133 | 134 | type SearchResults struct { 135 | Artists []Artist `json:"artists"` 136 | Albums []Album `json:"albums"` 137 | Tracks []Track `json:"tracks"` 138 | } 139 | 140 | // Query parameter structure 141 | type QueryParam struct { 142 | Name string 143 | Value string 144 | } 145 | 146 | // Download statistics 147 | type DownloadStats struct { 148 | SuccessCount int 149 | SkippedCount int 150 | FailedCount int 151 | FailedItems []string 152 | } 153 | 154 | // trackError holds information about a failed track download 155 | type trackError struct { 156 | Title string 157 | Err error 158 | } 159 | 160 | // ErrDownloadCancelled is returned when the user explicitly cancels a download operation. 161 | var ErrDownloadCancelled = fmt.Errorf("download cancelled by user") 162 | 163 | // ErrNoItemsSelected is returned when no items are selected for download. 164 | var ErrNoItemsSelected = fmt.Errorf("no items selected for download") -------------------------------------------------------------------------------- /warning_collector.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | ) 8 | 9 | // WarningType represents different types of warnings 10 | type WarningType int 11 | 12 | const ( 13 | MusicBrainzTrackWarning WarningType = iota 14 | MusicBrainzReleaseWarning 15 | CoverArtDownloadWarning 16 | CoverArtMetadataWarning 17 | AlbumFetchWarning 18 | TrackSkippedWarning 19 | ) 20 | 21 | // Warning represents a single warning with context 22 | type Warning struct { 23 | Type WarningType 24 | Message string 25 | Context string // Track/Album context 26 | Details string // Additional details like error message 27 | } 28 | 29 | // WarningCollector collects warnings during download operations 30 | type WarningCollector struct { 31 | warnings []Warning 32 | enabled bool 33 | } 34 | 35 | // NewWarningCollector creates a new warning collector 36 | func NewWarningCollector(enabled bool) *WarningCollector { 37 | return &WarningCollector{ 38 | warnings: make([]Warning, 0), 39 | enabled: enabled, 40 | } 41 | } 42 | 43 | // AddWarning adds a warning to the collector 44 | func (wc *WarningCollector) AddWarning(warningType WarningType, context, message, details string) { 45 | if !wc.enabled { 46 | return 47 | } 48 | 49 | warning := Warning{ 50 | Type: warningType, 51 | Message: message, 52 | Context: context, 53 | Details: details, 54 | } 55 | wc.warnings = append(wc.warnings, warning) 56 | } 57 | 58 | // AddMusicBrainzTrackWarning adds a MusicBrainz track lookup warning 59 | func (wc *WarningCollector) AddMusicBrainzTrackWarning(artist, title, details string) { 60 | context := fmt.Sprintf("%s - %s", artist, title) 61 | wc.AddWarning(MusicBrainzTrackWarning, context, "Failed to find MusicBrainz track", details) 62 | } 63 | 64 | // AddMusicBrainzReleaseWarning adds a MusicBrainz release lookup warning 65 | func (wc *WarningCollector) AddMusicBrainzReleaseWarning(artist, album, details string) { 66 | context := fmt.Sprintf("%s - %s", artist, album) 67 | wc.AddWarning(MusicBrainzReleaseWarning, context, "Failed to find MusicBrainz release", details) 68 | } 69 | 70 | // AddCoverArtDownloadWarning adds a cover art download warning 71 | func (wc *WarningCollector) AddCoverArtDownloadWarning(album, details string) { 72 | wc.AddWarning(CoverArtDownloadWarning, album, "Could not download cover art", details) 73 | } 74 | 75 | // AddCoverArtMetadataWarning adds a cover art metadata warning 76 | func (wc *WarningCollector) AddCoverArtMetadataWarning(context, details string) { 77 | wc.AddWarning(CoverArtMetadataWarning, context, "Failed to add cover art to metadata", details) 78 | } 79 | 80 | // AddAlbumFetchWarning adds an album fetch warning 81 | func (wc *WarningCollector) AddAlbumFetchWarning(trackTitle, trackID, details string) { 82 | context := fmt.Sprintf("%s (ID: %s)", trackTitle, trackID) 83 | wc.AddWarning(AlbumFetchWarning, context, "Could not fetch album info", details) 84 | } 85 | 86 | // AddTrackSkippedWarning adds a track skipped warning 87 | func (wc *WarningCollector) AddTrackSkippedWarning(trackPath string) { 88 | wc.AddWarning(TrackSkippedWarning, trackPath, "Track already exists", "") 89 | } 90 | 91 | // RemoveWarningsByTypeAndContext removes warnings of a specific type and context 92 | func (wc *WarningCollector) RemoveWarningsByTypeAndContext(warningType WarningType, context string) { 93 | if !wc.enabled { 94 | return 95 | } 96 | 97 | var filteredWarnings []Warning 98 | for _, warning := range wc.warnings { 99 | // Keep warnings that don't match the type and context 100 | if warning.Type != warningType || warning.Context != context { 101 | filteredWarnings = append(filteredWarnings, warning) 102 | } 103 | } 104 | wc.warnings = filteredWarnings 105 | } 106 | 107 | // RemoveMusicBrainzReleaseWarning removes a specific MusicBrainz release warning 108 | func (wc *WarningCollector) RemoveMusicBrainzReleaseWarning(artist, album string) { 109 | context := fmt.Sprintf("%s - %s", artist, album) 110 | wc.RemoveWarningsByTypeAndContext(MusicBrainzReleaseWarning, context) 111 | } 112 | 113 | // HasWarnings returns true if there are any warnings 114 | func (wc *WarningCollector) HasWarnings() bool { 115 | return len(wc.warnings) > 0 116 | } 117 | 118 | // GetWarningCount returns the total number of warnings 119 | func (wc *WarningCollector) GetWarningCount() int { 120 | return len(wc.warnings) 121 | } 122 | 123 | // GetWarningsByType returns warnings grouped by type 124 | func (wc *WarningCollector) GetWarningsByType() map[WarningType][]Warning { 125 | grouped := make(map[WarningType][]Warning) 126 | for _, warning := range wc.warnings { 127 | grouped[warning.Type] = append(grouped[warning.Type], warning) 128 | } 129 | return grouped 130 | } 131 | 132 | // PrintSummary prints a formatted summary of all warnings 133 | func (wc *WarningCollector) PrintSummary() { 134 | if !wc.HasWarnings() { 135 | return 136 | } 137 | 138 | colorWarning.Printf("\n⚠️ Warning Summary (%d warnings):\n", len(wc.warnings)) 139 | colorWarning.Println(strings.Repeat("─", 50)) 140 | 141 | grouped := wc.GetWarningsByType() 142 | 143 | // Sort warning types for consistent output 144 | var types []WarningType 145 | for warningType := range grouped { 146 | types = append(types, warningType) 147 | } 148 | sort.Slice(types, func(i, j int) bool { return types[i] < types[j] }) 149 | 150 | for _, warningType := range types { 151 | warnings := grouped[warningType] 152 | wc.printWarningTypeSection(warningType, warnings) 153 | } 154 | } 155 | 156 | // printWarningTypeSection prints warnings for a specific type 157 | func (wc *WarningCollector) printWarningTypeSection(warningType WarningType, warnings []Warning) { 158 | if len(warnings) == 0 { 159 | return 160 | } 161 | 162 | // Print section header 163 | sectionTitle := wc.getWarningTypeTitle(warningType) 164 | colorWarning.Printf("\n%s (%d):\n", sectionTitle, len(warnings)) 165 | 166 | // Group similar warnings to avoid repetition 167 | contextCounts := make(map[string]int) 168 | for _, warning := range warnings { 169 | contextCounts[warning.Context]++ 170 | } 171 | 172 | // Sort contexts for consistent output 173 | var contexts []string 174 | for context := range contextCounts { 175 | contexts = append(contexts, context) 176 | } 177 | sort.Strings(contexts) 178 | 179 | // Print warnings, showing count if multiple 180 | for _, context := range contexts { 181 | count := contextCounts[context] 182 | if count > 1 { 183 | colorWarning.Printf(" • %s (×%d)\n", context, count) 184 | } else { 185 | colorWarning.Printf(" • %s\n", context) 186 | } 187 | } 188 | } 189 | 190 | // getWarningTypeTitle returns a human-readable title for a warning type 191 | func (wc *WarningCollector) getWarningTypeTitle(warningType WarningType) string { 192 | switch warningType { 193 | case MusicBrainzTrackWarning: 194 | return "MusicBrainz Track Lookup Failures" 195 | case MusicBrainzReleaseWarning: 196 | return "MusicBrainz Release Lookup Failures" 197 | case CoverArtDownloadWarning: 198 | return "Cover Art Download Failures" 199 | case CoverArtMetadataWarning: 200 | return "Cover Art Metadata Failures" 201 | case AlbumFetchWarning: 202 | return "Album Information Fetch Failures" 203 | case TrackSkippedWarning: 204 | return "Tracks Skipped (Already Exist)" 205 | default: 206 | return "Other Warnings" 207 | } 208 | } -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "regexp" 10 | "strconv" 11 | "strings" 12 | 13 | "github.com/mattn/go-isatty" 14 | ) 15 | 16 | // GetUserInput prompts the user for input with a default value 17 | func GetUserInput(prompt, defaultValue string) string { 18 | if defaultValue != "" { 19 | prompt = fmt.Sprintf("%s [%s]", prompt, defaultValue) 20 | } 21 | colorPrompt.Print(prompt + ": ") 22 | scanner := bufio.NewScanner(os.Stdin) 23 | if scanner.Scan() { 24 | input := strings.TrimSpace(scanner.Text()) 25 | if input == "" && defaultValue != "" { 26 | return defaultValue 27 | } 28 | return input 29 | } 30 | return defaultValue 31 | } 32 | 33 | // SanitizeFileName cleans a string to make it safe for use as a file name 34 | func SanitizeFileName(name string) string { 35 | // Replace invalid characters with underscores 36 | invalidChars := []string{"<", ">", ":", `"`, `/`, `\\`, `|`, `?`, `*`, "\x00"} 37 | result := name 38 | for _, char := range invalidChars { 39 | result = strings.ReplaceAll(result, char, "_") 40 | } 41 | // Remove leading/trailing spaces and periods 42 | result = strings.Trim(result, " .") 43 | // Limit length to avoid filesystem issues 44 | if len(result) > 255 { 45 | result = result[:255] 46 | } 47 | // Ensure the name is not empty 48 | if result == "" { 49 | result = "unknown" 50 | } 51 | return result 52 | } 53 | 54 | // FileExists checks if a file exists at the given path 55 | func FileExists(path string) bool { 56 | info, err := os.Stat(path) 57 | if err != nil { 58 | return false 59 | } 60 | return !info.IsDir() 61 | } 62 | 63 | // CreateDirIfNotExists creates a directory if it does not exist 64 | func CreateDirIfNotExists(dir string) error { 65 | if _, err := os.Stat(dir); os.IsNotExist(err) { 66 | return os.MkdirAll(dir, 0755) 67 | } 68 | return nil 69 | } 70 | 71 | // GetTrackFilename generates a filename for a track 72 | func GetTrackFilename(trackNumber int, title string) string { 73 | if trackNumber == 0 { 74 | return fmt.Sprintf("%s.flac", SanitizeFileName(title)) 75 | } 76 | return fmt.Sprintf("%02d - %s.flac", trackNumber, SanitizeFileName(title)) 77 | } 78 | 79 | // LoadConfig loads configuration from a JSON file 80 | func LoadConfig(filePath string, config *Config) error { 81 | data, err := os.ReadFile(filePath) 82 | if err != nil { 83 | return fmt.Errorf("failed to read config file: %w", err) 84 | } 85 | if err := json.Unmarshal(data, config); err != nil { 86 | return fmt.Errorf("failed to unmarshal config: %w", err) 87 | } 88 | return nil 89 | } 90 | 91 | // SaveConfig saves configuration to a JSON file 92 | func SaveConfig(filePath string, config *Config) error { 93 | data, err := json.MarshalIndent(config, "", " ") 94 | if err != nil { 95 | return fmt.Errorf("failed to marshal config: %w", err) 96 | } 97 | dir := filepath.Dir(filePath) 98 | if err := CreateDirIfNotExists(dir); err != nil { 99 | return fmt.Errorf("failed to create config directory: %w", err) 100 | } 101 | if err := os.WriteFile(filePath, data, 0644); err != nil { 102 | return fmt.Errorf("failed to write config file: %w", err) 103 | } 104 | return nil 105 | } 106 | 107 | 108 | // TruncateString truncates a string to the specified length, adding ellipsis if truncated. 109 | func TruncateString(s string, maxLen int) string { 110 | if len(s) <= maxLen { 111 | return s 112 | } 113 | return s[:maxLen-3] + "..." 114 | } 115 | 116 | func idToString(id interface{}) string { 117 | switch v := id.(type) { 118 | case string: 119 | return v 120 | case float64: 121 | if v == float64(int64(v)) { 122 | return strconv.FormatInt(int64(v), 10) 123 | } 124 | return strconv.FormatFloat(v, 'f', -1, 64) 125 | case int: 126 | return strconv.Itoa(v) 127 | default: 128 | return "" 129 | } 130 | } 131 | 132 | // GetYesNoInput prompts the user for a yes/no input with a default value 133 | func GetYesNoInput(prompt string, defaultValue string) bool { 134 | for { 135 | input := GetUserInput(prompt, defaultValue) 136 | switch strings.ToLower(input) { 137 | case "y", "yes": 138 | return true 139 | case "n", "no": 140 | return false 141 | default: 142 | colorError.Printf("❌ Invalid input. Please enter 'y' or 'n'.\n") 143 | } 144 | } 145 | } 146 | 147 | // ParseSelectionInput parses a string like "1-7, 10, 12-15" into a slice of unique integers. 148 | func ParseSelectionInput(input string, max int) ([]int, error) { 149 | selected := make(map[int]bool) 150 | var result []int 151 | 152 | parts := strings.Split(input, ",") 153 | for _, part := range parts { 154 | part = strings.TrimSpace(part) 155 | if part == "" { 156 | continue 157 | } 158 | 159 | if strings.Contains(part, "-") { 160 | // Handle range, e.g., "1-7" 161 | rangeParts := strings.Split(part, "-") 162 | if len(rangeParts) != 2 { 163 | return nil, fmt.Errorf("invalid range format: %s", part) 164 | } 165 | start, err1 := strconv.Atoi(strings.TrimSpace(rangeParts[0])) 166 | if err1 != nil { 167 | return nil, fmt.Errorf("invalid start of range: %s", rangeParts[0]) 168 | } 169 | end, err2 := strconv.Atoi(strings.TrimSpace(rangeParts[1])) 170 | if err2 != nil { 171 | return nil, fmt.Errorf("invalid end of range: %s", rangeParts[1]) 172 | } 173 | 174 | if start > end { 175 | start, end = end, start // Swap if start is greater than end 176 | } 177 | 178 | for i := start; i <= end; i++ { 179 | if i >= 1 && i <= max && !selected[i] { 180 | selected[i] = true 181 | result = append(result, i) 182 | } 183 | } 184 | } else { 185 | // Handle single number, e.g., "10" 186 | num, err := strconv.Atoi(part) 187 | if err != nil { 188 | return nil, fmt.Errorf("invalid number: %s", part) 189 | } 190 | if num >= 1 && num <= max && !selected[num] { 191 | selected[num] = true 192 | result = append(result, num) 193 | } 194 | } 195 | } 196 | 197 | // Sort the results for consistent order 198 | // sort.Ints(result) // Not strictly necessary for functionality, but good for consistency 199 | 200 | return result, nil 201 | } 202 | 203 | func isTTY() bool { 204 | return isatty.IsTerminal(os.Stdout.Fd()) 205 | } 206 | 207 | // removeSuffix removes a suffix from a track title 208 | func removeSuffix(trackTitle string, suffix string) string { 209 | re := regexp.MustCompile(fmt.Sprintf(`(?i)( - |\s*\()((\d{4} )?)?(%s(ed)?( Version)?|Digital (Master?|%s(ed)?)|Remix)( \d{4})?(\))?$`, suffix, suffix)) 210 | return re.ReplaceAllString(trackTitle, "") 211 | } 212 | // VerifyFileSize checks if a file exists and matches the expected size 213 | func VerifyFileSize(filePath string, expectedSize int64) (bool, int64, error) { 214 | info, err := os.Stat(filePath) 215 | if err != nil { 216 | return false, 0, fmt.Errorf("failed to stat file: %w", err) 217 | } 218 | 219 | actualSize := info.Size() 220 | return actualSize == expectedSize, actualSize, nil 221 | } 222 | 223 | // VerifyFileIntegrity performs additional checks on downloaded files 224 | func VerifyFileIntegrity(filePath string, expectedSize int64, debug bool) error { 225 | if expectedSize <= 0 { 226 | if debug { 227 | fmt.Printf("DEBUG: Skipping file integrity check for %s - no expected size available\n", filePath) 228 | } 229 | return nil // Skip verification if no expected size 230 | } 231 | 232 | matches, actualSize, err := VerifyFileSize(filePath, expectedSize) 233 | if err != nil { 234 | return fmt.Errorf("file verification failed: %w", err) 235 | } 236 | 237 | if !matches { 238 | return fmt.Errorf("file size mismatch: expected %d bytes, got %d bytes", expectedSize, actualSize) 239 | } 240 | 241 | if debug { 242 | fmt.Printf("DEBUG: File integrity verified for %s - %d bytes\n", filePath, actualSize) 243 | } 244 | 245 | return nil 246 | } -------------------------------------------------------------------------------- /artist_downloader.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "path/filepath" 8 | "strconv" 9 | "strings" 10 | "sync" 11 | 12 | "github.com/cheggaaa/pb/v3" 13 | "golang.org/x/sync/semaphore" 14 | ) 15 | 16 | // DownloadArtistDiscography downloads an artist's complete discography 17 | func (api *DabAPI) DownloadArtistDiscography(ctx context.Context, artistID string, config *Config, debug bool, filter string, noConfirm bool) error { 18 | // Create warning collector based on config 19 | warningCollector := NewWarningCollector(config.WarningBehavior != "silent") 20 | 21 | artist, err := api.GetArtist(ctx, artistID, config, debug) 22 | if err != nil { 23 | return fmt.Errorf("failed to get artist info: %w", err) 24 | } 25 | 26 | colorInfo.Printf("🎤 Found artist: %s\n", artist.Name) 27 | 28 | if len(artist.Albums) == 0 { 29 | colorWarning.Println("⚠️ No albums found for this artist") 30 | return nil 31 | } 32 | 33 | // Categorize albums by type 34 | albums, eps, singles, other := api.categorizeAlbums(artist.Albums) 35 | 36 | // Show categorized content 37 | totalItems := len(albums) + len(eps) + len(singles) + len(other) 38 | colorInfo.Printf("📊 Found %d items:\n", totalItems) 39 | 40 | if len(albums) > 0 { 41 | colorInfo.Printf(" 🎵 Albums: %d\n", len(albums)) 42 | } 43 | if len(eps) > 0 { 44 | colorInfo.Printf(" 🎶 EPs: %d\n", len(eps)) 45 | } 46 | if len(singles) > 0 { 47 | colorInfo.Printf(" 🎤 Singles: %d\n", len(singles)) 48 | } 49 | if len(other) > 0 { 50 | colorInfo.Printf(" ❓ Others: %d\n", len(other)) 51 | } 52 | 53 | itemsToDownload := []Album{} 54 | if filter != "all" { 55 | filterParts := strings.Split(filter, ",") 56 | for _, part := range filterParts { 57 | switch strings.TrimSpace(part) { 58 | case "albums": 59 | itemsToDownload = append(itemsToDownload, albums...) 60 | case "eps": 61 | itemsToDownload = append(itemsToDownload, eps...) 62 | case "singles": 63 | itemsToDownload = append(itemsToDownload, singles...) 64 | } 65 | } 66 | } else { 67 | // Menu for download selection 68 | colorInfo.Println("\nWhat would you like to download?") 69 | fmt.Println("1) Everything (albums + EPs + singles)") 70 | fmt.Println("2) Only albums") 71 | fmt.Println("3) Only EPs") 72 | fmt.Println("4) Only singles") 73 | fmt.Println("5) Custom selection") 74 | 75 | choice := GetUserInput("Choose option (1-5, or q to quit)", "1") 76 | 77 | if strings.ToLower(choice) == "q" { 78 | colorWarning.Println("⚠️ Download cancelled by user.") 79 | return ErrDownloadCancelled 80 | } 81 | 82 | switch choice { 83 | case "1": 84 | itemsToDownload = append(itemsToDownload, albums...) 85 | itemsToDownload = append(itemsToDownload, eps...) 86 | itemsToDownload = append(itemsToDownload, singles...) 87 | itemsToDownload = append(itemsToDownload, other...) 88 | case "2": 89 | itemsToDownload = albums 90 | case "3": 91 | itemsToDownload = eps 92 | case "4": 93 | itemsToDownload = singles 94 | case "5": 95 | itemsToDownload = api.getCustomSelection(albums, eps, singles, other) 96 | if itemsToDownload == nil { 97 | colorWarning.Println("⚠️ Download cancelled by user.") 98 | return ErrDownloadCancelled 99 | } 100 | default: 101 | colorError.Println("❌ Invalid option, please try again.") 102 | return fmt.Errorf("invalid selection option") 103 | } 104 | } 105 | 106 | if len(itemsToDownload) == 0 { 107 | colorWarning.Println("⚠️ No items selected for download.") 108 | return ErrNoItemsSelected 109 | } 110 | 111 | colorInfo.Printf("\n📋 Items to download (%d):\n", len(itemsToDownload)) 112 | for i, item := range itemsToDownload { 113 | fmt.Printf("%d. [%s] %s (%s)\n", i+1, strings.ToUpper(item.Type), item.Title, item.ReleaseDate) 114 | } 115 | 116 | // Confirm download 117 | if !noConfirm { 118 | confirm := GetYesNoInput("Proceed with download? (y/N)", "n") 119 | if !confirm { 120 | colorWarning.Println("⚠️ Download cancelled.") 121 | return nil 122 | } 123 | } 124 | 125 | // Setup for download 126 | artistDir := filepath.Join(api.outputLocation, SanitizeFileName(artist.Name)) 127 | if err := CreateDirIfNotExists(artistDir); err != nil { 128 | return fmt.Errorf("failed to create artist directory: %w", err) 129 | } 130 | 131 | var wg sync.WaitGroup 132 | sem := semaphore.NewWeighted(int64(config.Parallelism)) 133 | stats := &DownloadStats{} 134 | errorChan := make(chan trackError, len(itemsToDownload)) 135 | var pool *pb.Pool 136 | if isTTY() { 137 | var poolErr error 138 | pool, poolErr = pb.StartPool() 139 | if poolErr != nil { 140 | colorError.Printf("❌ Failed to start progress bar pool: %v\n", poolErr) 141 | // Continue without the pool 142 | } 143 | } else { 144 | if debug { 145 | colorInfo.Println("DEBUG: isTTY() is false. Progress bars will not be displayed.") 146 | } 147 | } 148 | 149 | // Download each item 150 | 151 | for idx, item := range itemsToDownload { 152 | wg.Add(1) 153 | if err := sem.Acquire(ctx, 1); err != nil { 154 | colorError.Printf("Failed to acquire semaphore: %v\n", err) 155 | wg.Done() 156 | continue 157 | } 158 | 159 | go func(idx int, item Album) { 160 | defer wg.Done() 161 | defer sem.Release(1) 162 | 163 | colorInfo.Printf("🎵 Downloading %s %d/%d: %s\n", strings.ToUpper(item.Type), idx+1, len(itemsToDownload), item.Title) 164 | itemStats, err := api.DownloadAlbum(ctx, item.ID, config, debug, pool, warningCollector) 165 | if err != nil { 166 | errorChan <- trackError{item.Title, fmt.Errorf("item %s: %w", item.Title, err)} 167 | } else { 168 | stats.SuccessCount += itemStats.SuccessCount 169 | stats.SkippedCount += itemStats.SkippedCount 170 | stats.FailedCount += itemStats.FailedCount 171 | stats.FailedItems = append(stats.FailedItems, itemStats.FailedItems...) 172 | } 173 | }(idx, item) 174 | } 175 | 176 | // Wait for all downloads to finish 177 | wg.Wait() 178 | if pool != nil { 179 | pool.Stop() 180 | } 181 | close(errorChan) 182 | 183 | // Collect errors 184 | for err := range errorChan { 185 | stats.FailedCount++ 186 | stats.FailedItems = append(stats.FailedItems, fmt.Sprintf("%s: %v", err.Title, err.Err)) 187 | } 188 | 189 | // Show warning summary first if configured 190 | if config.WarningBehavior == "summary" { 191 | warningCollector.PrintSummary() 192 | } 193 | 194 | // Print download summary 195 | api.printDownloadStats(artist.Name, stats) 196 | 197 | return nil 198 | } 199 | 200 | // printDownloadStats prints the download statistics 201 | func (api *DabAPI) printDownloadStats(artistName string, stats *DownloadStats) { 202 | colorInfo.Printf("\n📊 Download Summary for %s:\n", artistName) 203 | colorSuccess.Printf("✅ Successfully downloaded: %d items\n", stats.SuccessCount) 204 | 205 | if stats.SkippedCount > 0 { 206 | colorWarning.Printf("⭐ Skipped (already exist): %d items\n", stats.SkippedCount) 207 | } 208 | 209 | if len(stats.FailedItems) > 0 { 210 | colorError.Printf("❌ Failed to download: %d items\n", len(stats.FailedItems)) 211 | for _, msg := range stats.FailedItems { 212 | colorError.Printf(" - %s\n", msg) 213 | } 214 | } 215 | 216 | colorSuccess.Printf("🎉 Artist discography downloaded to: %s\n", filepath.Join(api.outputLocation, SanitizeFileName(artistName))) 217 | } 218 | 219 | // getCustomSelection handles user's custom selection of albums/EPs/singles 220 | func (api *DabAPI) getCustomSelection(albums, eps, singles, other []Album) []Album { 221 | items := append(append(append([]Album{}, albums...), eps...), singles...) 222 | items = append(items, other...) 223 | 224 | fmt.Println("Available items:") 225 | for i, item := range items { 226 | fmt.Printf("%d. [%s] %s (%s)\n", i+1, strings.ToUpper(item.Type), item.Title, item.ReleaseDate) 227 | } 228 | 229 | for { 230 | input := GetUserInput("Enter selection (e.g., 1-5 | 1,5 | 1, or q to quit)", "none") 231 | if strings.ToLower(input) == "none" || strings.ToLower(input) == "q" { 232 | return nil 233 | } 234 | 235 | selected := api.parseSelection(input, items) 236 | if len(selected) > 0 { 237 | return selected 238 | } 239 | colorError.Printf("❌ Invalid selection. Please enter numbers between 1 and %d (e.g., 1-5, 1,5, 1).\n", len(items)) 240 | } 241 | } 242 | 243 | // categorizeAlbums categorizes albums by type and removes duplicates 244 | func (api *DabAPI) categorizeAlbums(allAlbums []Album) ([]Album, []Album, []Album, []Album) { 245 | // Deduplicate albums based on ID, Title, and ReleaseDate 246 | uniqueAlbums := make(map[string]Album) 247 | for _, album := range allAlbums { 248 | key := fmt.Sprintf("%s|%s|%s", album.ID, album.Title, album.ReleaseDate) 249 | uniqueAlbums[key] = album 250 | } 251 | 252 | albums := []Album{} 253 | eps := []Album{} 254 | singles := []Album{} 255 | other := []Album{} 256 | 257 | for _, album := range uniqueAlbums { 258 | switch strings.ToLower(album.Type) { 259 | case "album": 260 | albums = append(albums, album) 261 | case "ep": 262 | eps = append(eps, album) 263 | case "single": 264 | singles = append(singles, album) 265 | default: 266 | other = append(other, album) 267 | } 268 | } 269 | return albums, eps, singles, other 270 | } 271 | 272 | // parseSelection parses user input for album selection 273 | func (api *DabAPI) parseSelection(input string, allItems []Album) []Album { 274 | selected := []Album{} 275 | parts := strings.Split(input, ",") 276 | for _, part := range parts { 277 | part = strings.TrimSpace(part) 278 | if strings.Contains(part, "-") { 279 | rangeParts := strings.Split(part, "-") 280 | start, err1 := strconv.Atoi(strings.TrimSpace(rangeParts[0])) 281 | end, err2 := strconv.Atoi(strings.TrimSpace(rangeParts[1])) 282 | if err1 == nil && err2 == nil && start > 0 && end > 0 && start <= end && start <= len(allItems) && end <= len(allItems) { 283 | selected = append(selected, allItems[start-1:end]...) 284 | } 285 | } else { 286 | idx, err := strconv.Atoi(part) 287 | if err == nil && idx > 0 && idx <= len(allItems) { 288 | selected = append(selected, allItems[idx-1]) 289 | } 290 | } 291 | } 292 | return selected 293 | } -------------------------------------------------------------------------------- /musicbrainz.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" // Add this import 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "net" 9 | "net/http" 10 | "net/url" 11 | "time" 12 | 13 | "golang.org/x/time/rate" 14 | ) 15 | 16 | const ( 17 | musicBrainzAPI = "https://musicbrainz.org/ws/2/" 18 | musicBrainzUserAgent = "dab-downloader/2.0 ( prathxm.in@gmail.com )" // Replace with your actual email or project contact 19 | ) 20 | 21 | // MusicBrainzConfig holds retry configuration for MusicBrainz API calls 22 | type MusicBrainzConfig struct { 23 | MaxRetries int `json:"max_retries"` 24 | InitialDelay time.Duration `json:"initial_delay"` 25 | MaxDelay time.Duration `json:"max_delay"` 26 | } 27 | 28 | // DefaultMusicBrainzConfig returns sensible defaults for MusicBrainz API retry behavior 29 | func DefaultMusicBrainzConfig() MusicBrainzConfig { 30 | return MusicBrainzConfig{ 31 | MaxRetries: 5, 32 | InitialDelay: 2 * time.Second, 33 | MaxDelay: 60 * time.Second, 34 | } 35 | } 36 | 37 | // MusicBrainzClient for making requests to the MusicBrainz API 38 | type MusicBrainzClient struct { 39 | client *http.Client 40 | config MusicBrainzConfig 41 | debug bool 42 | rateLimiter *rate.Limiter 43 | baseURL string // Add this field 44 | } 45 | 46 | 47 | 48 | 49 | // NewMusicBrainzClientWithConfig creates a new MusicBrainz API client with custom retry configuration 50 | func NewMusicBrainzClientWithConfig(config MusicBrainzConfig) *MusicBrainzClient { 51 | return &MusicBrainzClient{ 52 | client: &http.Client{ 53 | Timeout: 30 * time.Second, 54 | }, 55 | config: config, 56 | debug: false, 57 | rateLimiter: rate.NewLimiter(rate.Every(time.Second), 1), 58 | baseURL: musicBrainzAPI, 59 | } 60 | } 61 | 62 | // NewMusicBrainzClientWithDebug creates a new MusicBrainz API client with debug mode 63 | func NewMusicBrainzClientWithDebug(debug bool) *MusicBrainzClient { 64 | return &MusicBrainzClient{ 65 | client: &http.Client{ 66 | Timeout: 30 * time.Second, 67 | }, 68 | config: DefaultMusicBrainzConfig(), 69 | debug: debug, 70 | rateLimiter: rate.NewLimiter(rate.Every(time.Second), 1), 71 | baseURL: musicBrainzAPI, 72 | } 73 | } 74 | 75 | // UpdateRetryConfig updates the retry configuration for the client 76 | func (mb *MusicBrainzClient) UpdateRetryConfig(config MusicBrainzConfig) { 77 | mb.config = config 78 | } 79 | 80 | // GetRetryConfig returns the current retry configuration 81 | func (mb *MusicBrainzClient) GetRetryConfig() MusicBrainzConfig { 82 | return mb.config 83 | } 84 | 85 | // SetDebug enables or disables debug logging for the client 86 | func (mb *MusicBrainzClient) SetDebug(debug bool) { 87 | mb.debug = debug 88 | } 89 | 90 | // get makes a GET request to the MusicBrainz API (internal method without retry) 91 | func (mb *MusicBrainzClient) get(path string) ([]byte, error) { 92 | var finalResp *http.Response // This will hold the successful response 93 | var err error 94 | 95 | // Construct the full URL for the MusicBrainz API request 96 | reqURL, err := url.Parse(mb.baseURL + path) 97 | if err != nil { 98 | return nil, fmt.Errorf("failed to parse MusicBrainz API URL: %w", err) 99 | } 100 | 101 | // Wait for the rate limiter 102 | mb.rateLimiter.Wait(context.Background()) 103 | 104 | err = RetryWithBackoffForHTTPWithDebug( 105 | mb.config.MaxRetries, // maxRetries from client config 106 | mb.config.InitialDelay, // initialDelay from client config 107 | mb.config.MaxDelay, // maxDelay from client config 108 | func() error { 109 | req, err := http.NewRequest("GET", reqURL.String(), nil) 110 | if err != nil { 111 | return fmt.Errorf("failed to create request: %w", err) 112 | } 113 | req.Header.Set("User-Agent", musicBrainzUserAgent) 114 | req.Header.Set("Accept", "application/json") 115 | 116 | resp, err := mb.client.Do(req) // Use mb.client 117 | if err != nil { 118 | // Check for network-related errors that are not HTTP errors 119 | if netErr, ok := err.(net.Error); ok && netErr.Timeout() { 120 | return &HTTPError{StatusCode: http.StatusGatewayTimeout, Status: "Gateway Timeout", Message: err.Error()} 121 | } 122 | return err // Non-retryable network error or other error 123 | } 124 | 125 | // If the status code is retryable, return an HTTPError 126 | if IsRetryableHTTPError(&HTTPError{StatusCode: resp.StatusCode}) { 127 | // Close the body of the retryable response to prevent resource leaks 128 | resp.Body.Close() 129 | return &HTTPError{StatusCode: resp.StatusCode, Status: resp.Status, Message: "Retryable HTTP error"} 130 | } 131 | 132 | finalResp = resp // Assign the successful response to the outer variable 133 | return nil // Success or non-retryable HTTP error 134 | }, 135 | mb.debug, // Use client's debug setting 136 | ) 137 | 138 | if err != nil { 139 | return nil, fmt.Errorf("failed to perform request after retries: %w", err) 140 | } 141 | 142 | defer finalResp.Body.Close() 143 | 144 | if finalResp.StatusCode != http.StatusOK { 145 | // Create structured HTTP error for retry logic 146 | body, _ := ioutil.ReadAll(finalResp.Body) 147 | message := string(body) 148 | if len(message) > 200 { 149 | message = message[:200] + "..." 150 | } 151 | return nil, &HTTPError{ 152 | StatusCode: finalResp.StatusCode, 153 | Status: finalResp.Status, 154 | Message: message, 155 | } 156 | } 157 | 158 | body, err := ioutil.ReadAll(finalResp.Body) 159 | if err != nil { 160 | return nil, fmt.Errorf("failed to read response body: %w", err) 161 | } 162 | return body, nil 163 | } 164 | 165 | // getWithRetry makes a GET request to the MusicBrainz API with retry logic for retryable errors 166 | func (mb *MusicBrainzClient) getWithRetry(path string) ([]byte, error) { 167 | var result []byte 168 | var err error 169 | 170 | retryErr := RetryWithBackoffForHTTPWithDebug( 171 | mb.config.MaxRetries, 172 | mb.config.InitialDelay, 173 | mb.config.MaxDelay, 174 | func() error { 175 | result, err = mb.get(path) 176 | return err 177 | }, 178 | mb.debug, 179 | ) 180 | 181 | if retryErr != nil { 182 | return nil, retryErr 183 | } 184 | return result, nil 185 | } 186 | 187 | // GetTrackMetadata fetches track metadata from MusicBrainz by MBID 188 | func (mb *MusicBrainzClient) GetTrackMetadata(mbid string) (*MusicBrainzTrack, error) { 189 | path := fmt.Sprintf("recording/%s?inc=artists+releases+url-rels", mbid) 190 | body, err := mb.getWithRetry(path) 191 | if err != nil { 192 | return nil, err 193 | } 194 | 195 | var track MusicBrainzTrack 196 | if err := json.Unmarshal(body, &track); err != nil { 197 | return nil, fmt.Errorf("failed to unmarshal MusicBrainz track metadata: %w", err) 198 | } 199 | return &track, nil 200 | } 201 | 202 | // GetReleaseMetadata fetches release (album) metadata from MusicBrainz by MBID 203 | func (mb *MusicBrainzClient) GetReleaseMetadata(mbid string) (*MusicBrainzRelease, error) { 204 | path := fmt.Sprintf("release/%s?inc=artists+labels+recordings+url-rels+release-groups", mbid) 205 | body, err := mb.getWithRetry(path) 206 | if err != nil { 207 | return nil, err 208 | } 209 | 210 | var release MusicBrainzRelease 211 | if err := json.Unmarshal(body, &release); err != nil { 212 | return nil, fmt.Errorf("failed to unmarshal MusicBrainz release metadata: %w", err) 213 | } 214 | return &release, nil 215 | } 216 | 217 | // SearchTrack searches for a track on MusicBrainz 218 | func (mb *MusicBrainzClient) SearchTrack(artist, album, title string) (*MusicBrainzTrack, error) { 219 | query := fmt.Sprintf("artist:\"%s\" AND release:\"%s\" AND recording:\"%s\"", artist, album, title) 220 | path := fmt.Sprintf("recording?query=%s&limit=1", url.QueryEscape(query)) 221 | body, err := mb.getWithRetry(path) 222 | if err != nil { 223 | return nil, err 224 | } 225 | 226 | var searchResult struct { 227 | Recordings []MusicBrainzTrack `json:"recordings"` 228 | } 229 | if err := json.Unmarshal(body, &searchResult); err != nil { 230 | return nil, fmt.Errorf("failed to unmarshal MusicBrainz track search result: %w", err) 231 | } 232 | 233 | if len(searchResult.Recordings) > 0 { 234 | return &searchResult.Recordings[0], nil 235 | } 236 | 237 | return nil, fmt.Errorf("no track found on MusicBrainz for: %s - %s - %s", artist, album, title) 238 | } 239 | 240 | // SearchRelease searches for a release on MusicBrainz 241 | func (mb *MusicBrainzClient) SearchRelease(artist, album string) (*MusicBrainzRelease, error) { 242 | query := fmt.Sprintf("artist:\"%s\" AND release:\"%s\"", artist, album) 243 | path := fmt.Sprintf("release?query=%s&limit=1", url.QueryEscape(query)) 244 | body, err := mb.getWithRetry(path) 245 | if err != nil { 246 | return nil, err 247 | } 248 | 249 | var searchResult struct { 250 | Releases []MusicBrainzRelease `json:"releases"` 251 | } 252 | if err := json.Unmarshal(body, &searchResult); err != nil { 253 | return nil, fmt.Errorf("failed to unmarshal MusicBrainz release search result: %w", err) 254 | } 255 | 256 | if len(searchResult.Releases) > 0 { 257 | return &searchResult.Releases[0], nil 258 | } 259 | 260 | return nil, fmt.Errorf("no release found on MusicBrainz for: %s - %s", artist, album) 261 | } 262 | 263 | // MusicBrainzTrack represents a simplified MusicBrainz recording (track) 264 | type MusicBrainzTrack struct { 265 | ID string `json:"id"` 266 | Title string `json:"title"` 267 | ArtistCredit []struct { 268 | Artist struct { 269 | ID string `json:"id"` 270 | Name string `json:"name"` 271 | } `json:"artist"` 272 | } `json:"artist-credit"` 273 | Releases []struct { 274 | ID string `json:"id"` 275 | Title string `json:"title"` 276 | Date string `json:"date"` 277 | Media []struct { 278 | Format string `json:"format"` 279 | Discs []struct { 280 | ID string `json:"id"` 281 | } `json:"discs"` 282 | Tracks []struct { 283 | ID string `json:"id"` 284 | Number string `json:"number"` 285 | Title string `json:"title"` 286 | Length int `json:"length"` 287 | } `json:"tracks"` 288 | } `json:"media"` 289 | } `json:"releases"` 290 | Length int `json:"length"` // Duration in milliseconds 291 | } 292 | 293 | // MusicBrainzRelease represents a simplified MusicBrainz release (album) 294 | type MusicBrainzRelease struct { 295 | ID string `json:"id"` 296 | Title string `json:"title"` 297 | Status string `json:"status"` 298 | Date string `json:"date"` 299 | Country string `json:"country"` 300 | ArtistCredit []struct { 301 | Artist struct { 302 | ID string `json:"id"` 303 | Name string `json:"name"` 304 | } `json:"artist"` 305 | } `json:"artist-credit"` 306 | LabelInfo []struct { 307 | CatalogNumber string `json:"catalog-number"` 308 | Label struct { 309 | ID string `json:"id"` 310 | Name string `json:"name"` 311 | } `json:"label"` 312 | } `json:"label-info"` 313 | Media []struct { 314 | Format string `json:"format"` 315 | Discs []struct { 316 | ID string `json:"id"` 317 | } `json:"discs"` 318 | Tracks []struct { 319 | ID string `json:"id"` 320 | Number string `json:"number"` 321 | Title string `json:"title"` 322 | Length int `json:"length"` 323 | } `json:"tracks"` 324 | } `json:"media"` 325 | TextRepresentation struct { 326 | Language string `json:"language"` 327 | Script string `json:"script"` 328 | } `json:"text-representation"` 329 | Packaging string `json:"packaging"` 330 | Barcode string `json:"barcode"` 331 | ReleaseGroup ReleaseGroup `json:"release-group"` 332 | // Add other fields as needed 333 | } 334 | 335 | type ReleaseGroup struct { 336 | ID string `json:"id"` 337 | } -------------------------------------------------------------------------------- /navidrome.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/hex" 6 | "encoding/json" 7 | "fmt" 8 | "io/ioutil" 9 | "log" 10 | "net/http" 11 | "net/url" 12 | "strconv" 13 | "strings" 14 | 15 | subsonic "github.com/delucks/go-subsonic" 16 | ) 17 | 18 | // Authenticate authenticates the client with the navidrome api 19 | func (n *NavidromeClient) Authenticate() error { 20 | // Ping the server to get the salt 21 | pingURL := fmt.Sprintf("%s/rest/ping.view?v=1.16.1&c=dab-downloader&f=json", n.URL) 22 | resp, err := http.Get(pingURL) 23 | if err != nil { 24 | return err 25 | } 26 | defer resp.Body.Close() 27 | 28 | body, err := ioutil.ReadAll(resp.Body) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | var pingResponse struct { 34 | SubsonicResponse struct { 35 | Status string `json:"status"` 36 | Salt string `json:"salt"` 37 | } `json:"subsonic-response"` 38 | } 39 | 40 | if err := json.Unmarshal(body, &pingResponse); err != nil { 41 | return err 42 | } 43 | 44 | if pingResponse.SubsonicResponse.Status != "ok" { 45 | // Try with auth 46 | pingURL = fmt.Sprintf("%s/rest/ping.view?u=%s&p=%s&v=1.16.1&c=dab-downloader&f=json", n.URL, n.Username, n.Password) 47 | resp, err = http.Get(pingURL) 48 | if err != nil { 49 | return err 50 | } 51 | defer resp.Body.Close() 52 | 53 | body, err = ioutil.ReadAll(resp.Body) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | if err := json.Unmarshal(body, &pingResponse); err != nil { 59 | return err 60 | } 61 | 62 | if pingResponse.SubsonicResponse.Status != "ok" { 63 | return fmt.Errorf("ping failed: %s", pingResponse.SubsonicResponse.Status) 64 | } 65 | } 66 | 67 | n.Salt = pingResponse.SubsonicResponse.Salt 68 | n.Token = getSaltedPassword(n.Password, n.Salt) 69 | 70 | n.Client = subsonic.Client{ 71 | Client: http.DefaultClient, 72 | BaseUrl: n.URL, 73 | User: n.Username, 74 | ClientName: "dab-downloader", 75 | PasswordAuth: true, 76 | } 77 | return n.Client.Authenticate(n.Password) 78 | } 79 | 80 | // SearchTrack searches for a track on the navidrome server 81 | func (n *NavidromeClient) SearchTrack(trackName, artistName, albumName string) (*subsonic.Child, error) { 82 | log.Printf("Searching Navidrome for Track: %s, Artist: %s, Album: %s", trackName, artistName, albumName) 83 | 84 | // First, search for the album 85 | album, err := n.SearchAlbum(albumName, artistName) 86 | if err != nil { 87 | log.Printf("Error searching for album '%s' by '%s': %v", albumName, artistName, err) 88 | // Continue to track-based search as a fallback 89 | } 90 | 91 | if album != nil { 92 | // Album found, now get the tracks of the album 93 | albumData, err := n.Client.GetAlbum(album.ID) 94 | if err != nil { 95 | log.Printf("Error getting album details for '%s': %v", albumName, err) 96 | } else { 97 | for _, song := range albumData.Song { 98 | if strings.EqualFold(song.Title, trackName) { 99 | log.Printf("Found exact track match in album: %s by %s (ID: %s)", song.Title, song.Artist, song.ID) 100 | return song, nil 101 | } 102 | } 103 | } 104 | } 105 | 106 | // Fallback to original search logic if album search is not conclusive 107 | log.Printf("Fallback: Searching for track directly.") 108 | // Try searching with track name and artist name combined first 109 | combinedQuery := fmt.Sprintf("%s %s", trackName, artistName) 110 | log.Printf("Trying combined query first: '%s'", combinedQuery) 111 | searchResult, err := n.Client.Search2(combinedQuery, map[string]string{"songCount": "5"}) 112 | if err != nil { 113 | log.Printf("Error during combined search for '%s': %v", combinedQuery, err) 114 | // Don't return yet, fall back to track name only 115 | } 116 | 117 | if searchResult != nil && len(searchResult.Song) > 0 { 118 | log.Printf("Search result for combined query '%s': %v songs found", combinedQuery, len(searchResult.Song)) 119 | // Check for exact match, as search can be fuzzy 120 | for _, song := range searchResult.Song { 121 | if strings.EqualFold(song.Title, trackName) && strings.EqualFold(song.Artist, artistName) { 122 | log.Printf(" Found exact match: %s by %s (ID: %s)", song.Title, song.Artist, song.ID) 123 | return song, nil 124 | } 125 | } 126 | // If no exact match, return the first result as a best guess 127 | log.Printf(" No exact match found, returning first result as best guess: %s by %s (ID: %s)", searchResult.Song[0].Title, searchResult.Song[0].Artist, searchResult.Song[0].ID) 128 | return searchResult.Song[0], nil 129 | } 130 | 131 | // Fallback to searching with just the track name 132 | log.Printf("No results from combined query, falling back to track name only: '%s'", trackName) 133 | searchResult, err = n.Client.Search2(trackName, map[string]string{"songCount": "10"}) 134 | if err != nil { 135 | log.Printf("Error during track name search for '%s': %v", trackName, err) 136 | return nil, err 137 | } 138 | 139 | if searchResult != nil && len(searchResult.Song) > 0 { 140 | log.Printf("Search result for '%s': %v songs found", trackName, len(searchResult.Song)) 141 | for _, song := range searchResult.Song { 142 | log.Printf(" Found song: %s by %s (ID: %s)", song.Title, song.Artist, song.ID) 143 | // Check if the artist name matches (case-insensitive) 144 | if strings.EqualFold(song.Artist, artistName) { 145 | log.Printf(" Exact artist match found for '%s'", artistName) 146 | return song, nil 147 | } 148 | } 149 | } 150 | 151 | log.Printf("Track '%s' by '%s' not found after all attempts.", trackName, artistName) 152 | return nil, nil 153 | } 154 | 155 | // SearchAlbum searches for an album on the navidrome server 156 | func (n *NavidromeClient) SearchAlbum(albumName string, artistName string) (*subsonic.Child, error) { 157 | log.Printf("Searching Navidrome for Album: %s, Artist: %s", albumName, artistName) 158 | 159 | // Search for the album by name 160 | searchResult, err := n.Client.Search2(albumName, map[string]string{"albumCount": "5"}) 161 | if err != nil { 162 | return nil, fmt.Errorf("error searching for album '%s': %w", albumName, err) 163 | } 164 | 165 | if searchResult != nil && len(searchResult.Album) > 0 { 166 | log.Printf("Found %d albums for query '%s'", len(searchResult.Album), albumName) 167 | for _, album := range searchResult.Album { 168 | log.Printf(" Checking album: %s by %s (ID: %s)", album.Title, album.Artist, album.ID) 169 | if strings.EqualFold(album.Title, albumName) && strings.EqualFold(album.Artist, artistName) { 170 | log.Printf(" Found exact album match: %s by %s (ID: %s)", album.Title, album.Artist, album.ID) 171 | return album, nil 172 | } 173 | } 174 | log.Printf("No exact album match found for '%s' by '%s'.", albumName, artistName) 175 | } else { 176 | log.Printf("No albums found for query '%s'", albumName) 177 | } 178 | 179 | return nil, nil // Album not found 180 | } 181 | 182 | // CreatePlaylist creates a new playlist on the navidrome server 183 | func (n *NavidromeClient) CreatePlaylist(name string) error { 184 | // Use url.Values to properly encode the playlist name 185 | data := url.Values{} 186 | data.Set("name", name) 187 | 188 | // Construct the URL for creating the playlist 189 | createURL := fmt.Sprintf("%s/rest/createPlaylist.view?%s&u=%s&t=%s&s=%s&v=1.16.1&c=dab-downloader&f=json", 190 | n.URL, data.Encode(), n.Username, n.Token, n.Salt) 191 | 192 | // Create a new HTTP GET request 193 | req, err := http.NewRequest("GET", createURL, nil) 194 | if err != nil { 195 | return fmt.Errorf("failed to create request: %w", err) 196 | } 197 | 198 | // Execute the request 199 | resp, err := http.DefaultClient.Do(req) 200 | if err != nil { 201 | return fmt.Errorf("failed to execute request: %w", err) 202 | } 203 | defer resp.Body.Close() 204 | 205 | // Read the response body 206 | body, err := ioutil.ReadAll(resp.Body) 207 | if err != nil { 208 | return fmt.Errorf("failed to read response body: %w", err) 209 | } 210 | 211 | // Check the response status 212 | if resp.StatusCode != http.StatusOK { 213 | return fmt.Errorf("failed to create playlist: status code %d, body: %s", resp.StatusCode, string(body)) 214 | } 215 | 216 | return nil 217 | } 218 | 219 | // UpdatePlaylist updates a playlist 220 | func (n *NavidromeClient) UpdatePlaylist(playlistID string, name *string, comment *string, public *bool) error { 221 | updateMap := make(map[string]string) 222 | if name != nil { 223 | updateMap["name"] = *name 224 | } 225 | if comment != nil { 226 | updateMap["comment"] = *comment 227 | } 228 | if public != nil { 229 | updateMap["public"] = strconv.FormatBool(*public) 230 | } 231 | err := n.Client.UpdatePlaylist(playlistID, updateMap) 232 | if err != nil { 233 | return err 234 | } 235 | return nil 236 | } 237 | 238 | // AddTracksToPlaylist adds multiple tracks to a playlist in a single call 239 | func (n *NavidromeClient) AddTracksToPlaylist(playlistID string, trackIDs []string) error { 240 | params := url.Values{} 241 | params.Add("playlistId", playlistID) 242 | params.Add("u", n.Username) 243 | params.Add("t", n.Token) 244 | params.Add("s", n.Salt) 245 | params.Add("v", "1.16.1") 246 | params.Add("c", "dab-downloader") 247 | params.Add("f", "json") 248 | 249 | for _, songID := range trackIDs { 250 | params.Add("songIdToAdd", songID) 251 | } 252 | 253 | updateURL := fmt.Sprintf("%s/rest/updatePlaylist.view?%s", n.URL, params.Encode()) 254 | 255 | log.Printf("Calling update playlist URL: %s", updateURL) 256 | 257 | // Create a new HTTP GET request 258 | req, err := http.NewRequest("GET", updateURL, nil) 259 | if err != nil { 260 | return fmt.Errorf("failed to create request: %w", err) 261 | } 262 | 263 | // Execute the request 264 | resp, err := http.DefaultClient.Do(req) 265 | if err != nil { 266 | return fmt.Errorf("failed to execute request: %w", err) 267 | } 268 | defer resp.Body.Close() 269 | 270 | // Read the response body 271 | body, err := ioutil.ReadAll(resp.Body) 272 | if err != nil { 273 | return fmt.Errorf("failed to read response body: %w", err) 274 | } 275 | 276 | // Check the response status 277 | if resp.StatusCode != http.StatusOK { 278 | return fmt.Errorf("failed to update playlist: status code %d, body: %s", resp.StatusCode, string(body)) 279 | } 280 | 281 | // Unmarshal the response to check for Subsonic errors 282 | var subsonicResponse struct { 283 | SubsonicResponse struct { 284 | Status string `json:"status"` 285 | Error struct { 286 | Code int `json:"code"` 287 | Message string `json:"message"` 288 | } `json:"error"` 289 | } `json:"subsonic-response"` 290 | } 291 | 292 | if err := json.Unmarshal(body, &subsonicResponse); err != nil { 293 | return fmt.Errorf("failed to unmarshal response: %w", err) 294 | } 295 | 296 | if subsonicResponse.SubsonicResponse.Status == "failed" { 297 | return fmt.Errorf("failed to update playlist: %s (code %d)", subsonicResponse.SubsonicResponse.Error.Message, subsonicResponse.SubsonicResponse.Error.Code) 298 | } 299 | 300 | return nil 301 | } 302 | 303 | // GetPlaylistTracks returns the tracks in a playlist 304 | func (n *NavidromeClient) GetPlaylistTracks(playlistID string) ([]*subsonic.Child, error) { 305 | playlist, err := n.Client.GetPlaylist(playlistID) 306 | if err != nil { 307 | return nil, err 308 | } 309 | return playlist.Entry, nil 310 | } 311 | 312 | // SearchPlaylist searches for a playlist by name and returns its ID 313 | func (n *NavidromeClient) SearchPlaylist(playlistName string) (string, error) { 314 | playlists, err := n.Client.GetPlaylists(map[string]string{}) 315 | if err != nil { 316 | return "", err 317 | } 318 | 319 | for _, playlist := range playlists { 320 | if playlist.Name == playlistName { 321 | return playlist.ID, nil 322 | } 323 | } 324 | 325 | return "", fmt.Errorf("playlist '%s' not found", playlistName) 326 | } 327 | 328 | // getSaltedPassword returns the salted password for navidrome 329 | func getSaltedPassword(password string, salt string) string { 330 | hasher := md5.New() 331 | hasher.Write([]byte(password + salt)) 332 | return hex.EncodeToString(hasher.Sum(nil)) 333 | } -------------------------------------------------------------------------------- /metadata.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | 7 | "github.com/go-flac/go-flac" 8 | "github.com/go-flac/flacpicture" 9 | "github.com/go-flac/flacvorbis" 10 | 11 | ) 12 | 13 | var mbClient = NewMusicBrainzClientWithDebug(false) // Global instance of MusicBrainzClient 14 | 15 | // SetMusicBrainzDebug sets debug mode for the global MusicBrainz client 16 | func SetMusicBrainzDebug(debug bool) { 17 | mbClient.SetDebug(debug) 18 | } 19 | 20 | // AlbumMetadataCache holds cached MusicBrainz release metadata for albums 21 | type AlbumMetadataCache struct { 22 | releases map[string]*MusicBrainzRelease // key: "artist|album" 23 | mu sync.RWMutex 24 | } 25 | 26 | // Global cache instance 27 | var albumCache = &AlbumMetadataCache{ 28 | releases: make(map[string]*MusicBrainzRelease), 29 | } 30 | 31 | // getCacheKey generates a cache key for an album 32 | func getCacheKey(artist, album string) string { 33 | return fmt.Sprintf("%s|%s", artist, album) 34 | } 35 | 36 | // GetCachedRelease retrieves cached release metadata 37 | func (cache *AlbumMetadataCache) GetCachedRelease(artist, album string) *MusicBrainzRelease { 38 | cache.mu.RLock() 39 | defer cache.mu.RUnlock() 40 | return cache.releases[getCacheKey(artist, album)] 41 | } 42 | 43 | // SetCachedRelease stores release metadata in cache 44 | func (cache *AlbumMetadataCache) SetCachedRelease(artist, album string, release *MusicBrainzRelease) { 45 | cache.mu.Lock() 46 | defer cache.mu.Unlock() 47 | cache.releases[getCacheKey(artist, album)] = release 48 | } 49 | 50 | // ClearCache clears the album metadata cache (useful for testing or memory management) 51 | func (cache *AlbumMetadataCache) ClearCache() { 52 | cache.mu.Lock() 53 | defer cache.mu.Unlock() 54 | cache.releases = make(map[string]*MusicBrainzRelease) 55 | } 56 | 57 | // AddMetadata adds comprehensive metadata to a FLAC file 58 | func AddMetadata(filePath string, track Track, album *Album, coverData []byte, totalTracks int, warningCollector *WarningCollector) error { 59 | return AddMetadataWithDebug(filePath, track, album, coverData, totalTracks, warningCollector, false) 60 | } 61 | 62 | // AddMetadataWithDebug adds comprehensive metadata to a FLAC file with debug mode support 63 | func AddMetadataWithDebug(filePath string, track Track, album *Album, coverData []byte, totalTracks int, warningCollector *WarningCollector, debug bool) error { 64 | // Set debug mode for MusicBrainz client 65 | mbClient.SetDebug(debug) 66 | // Open the FLAC file 67 | f, err := flac.ParseFile(filePath) 68 | if err != nil { 69 | return fmt.Errorf("failed to parse FLAC file: %w", err) 70 | } 71 | 72 | // Remove existing VORBIS_COMMENT and PICTURE blocks to ensure clean metadata 73 | var newMetaData []*flac.MetaDataBlock 74 | for _, block := range f.Meta { 75 | if block.Type != flac.VorbisComment && block.Type != flac.Picture { 76 | newMetaData = append(newMetaData, block) 77 | } 78 | } 79 | f.Meta = newMetaData 80 | 81 | // Create a new Vorbis comment block with comprehensive metadata 82 | comment := flacvorbis.New() 83 | 84 | // Essential fields for music players 85 | addField(comment, flacvorbis.FIELD_TITLE, track.Title) 86 | addField(comment, flacvorbis.FIELD_ARTIST, track.Artist) 87 | 88 | // Album information - crucial for preventing "Unknown Album" 89 | albumTitle := getAlbumTitle(track, album) 90 | addField(comment, flacvorbis.FIELD_ALBUM, albumTitle) 91 | 92 | // Album Artist - important for compilation albums and proper grouping 93 | albumArtist := getAlbumArtist(track, album) 94 | addField(comment, "ALBUMARTIST", albumArtist) 95 | 96 | // Track and disc numbers 97 | trackNumber := track.TrackNumber 98 | if trackNumber == 0 { 99 | trackNumber = 1 100 | } 101 | addField(comment, flacvorbis.FIELD_TRACKNUMBER, fmt.Sprintf("%d", trackNumber)) 102 | 103 | if totalTracks > 0 { 104 | addField(comment, "TOTALTRACKS", fmt.Sprintf("%d", totalTracks)) 105 | } else if album != nil && album.TotalTracks > 0 { 106 | addField(comment, "TOTALTRACKS", fmt.Sprintf("%d", album.TotalTracks)) 107 | } 108 | 109 | discNumber := track.DiscNumber 110 | if discNumber == 0 { 111 | discNumber = 1 112 | } 113 | addField(comment, "DISCNUMBER", fmt.Sprintf("%d", discNumber)) 114 | 115 | totalDiscs := 1 116 | if album != nil && album.TotalDiscs > 0 { 117 | totalDiscs = album.TotalDiscs 118 | } 119 | addField(comment, "TOTALDISCS", fmt.Sprintf("%d", totalDiscs)) 120 | 121 | // Date and year information 122 | releaseDate := getReleaseDate(track, album) 123 | if releaseDate != "" { 124 | addField(comment, flacvorbis.FIELD_DATE, releaseDate) 125 | if len(releaseDate) >= 4 { 126 | year := releaseDate[:4] 127 | addField(comment, "YEAR", year) 128 | addField(comment, "ORIGINALDATE", releaseDate) 129 | } 130 | } else if track.Year != "" { 131 | addField(comment, "YEAR", track.Year) 132 | addField(comment, flacvorbis.FIELD_DATE, track.Year) 133 | } 134 | 135 | // Genre information 136 | genre := getGenre(track, album) 137 | if genre != "" && genre != "Unknown" { 138 | addField(comment, "GENRE", genre) 139 | } 140 | 141 | // Additional metadata fields 142 | if track.Composer != "" { 143 | addField(comment, "COMPOSER", track.Composer) 144 | } 145 | if track.Producer != "" { 146 | addField(comment, "PRODUCER", track.Producer) 147 | } 148 | if track.ISRC != "" { 149 | addField(comment, "ISRC", track.ISRC) 150 | } 151 | if track.Copyright != "" { 152 | addField(comment, "COPYRIGHT", track.Copyright) 153 | } else if album != nil && album.Copyright != "" { 154 | addField(comment, "COPYRIGHT", album.Copyright) 155 | } 156 | 157 | // Label information 158 | if album != nil && album.Label != nil { 159 | if label, ok := album.Label.(string); ok { 160 | addField(comment, "LABEL", label) 161 | } 162 | } 163 | 164 | // Catalog numbers 165 | if album != nil && album.UPC != "" { 166 | addField(comment, "CATALOGNUMBER", album.UPC) 167 | addField(comment, "UPC", album.UPC) 168 | } 169 | 170 | // Technical and source information 171 | // addField(comment, "MUSICBRAINZ_TRACKID", idToString(track.ID)) // This is wrong 172 | // if album != nil && album.ID != "" { 173 | // addField(comment, "MUSICBRAINZ_ALBUMID", album.ID) // This is wrong 174 | // } 175 | 176 | // Fetch and add MusicBrainz metadata with optimized caching 177 | addMusicBrainzMetadata(comment, track, album, albumTitle, warningCollector) 178 | 179 | addField(comment, "ENCODER", "EnhancedFLACDownloader/2.0") 180 | addField(comment, "ENCODING", "FLAC") 181 | addField(comment, "SOURCE", "DAB") 182 | 183 | // Duration if available 184 | if track.Duration > 0 { 185 | addField(comment, "LENGTH", fmt.Sprintf("%d", track.Duration)) 186 | } 187 | 188 | // Marshal the comment to a FLAC metadata block 189 | vorbisCommentBlock := comment.Marshal() 190 | f.Meta = append(f.Meta, &vorbisCommentBlock) 191 | 192 | // Add cover art if available 193 | if err := addCoverArt(f, coverData); err != nil { 194 | if warningCollector != nil { 195 | context := fmt.Sprintf("%s - %s", track.Artist, track.Title) 196 | warningCollector.AddCoverArtMetadataWarning(context, err.Error()) 197 | } 198 | } 199 | 200 | // Save the file with new metadata 201 | if err := f.Save(filePath); err != nil { 202 | return fmt.Errorf("failed to save FLAC file with metadata: %w", err) 203 | } 204 | 205 | return nil 206 | } 207 | 208 | // addField adds a field to vorbis comment only if value is not empty 209 | func addField(comment *flacvorbis.MetaDataBlockVorbisComment, field, value string) { 210 | if value != "" { 211 | comment.Add(field, value) 212 | } 213 | } 214 | 215 | // getAlbumTitle determines the best album title to use 216 | func getAlbumTitle(track Track, album *Album) string { 217 | if album != nil && album.Title != "" { 218 | return album.Title 219 | } 220 | if track.Album != "" { 221 | return track.Album 222 | } 223 | return "Unknown Album" 224 | } 225 | 226 | // getAlbumArtist determines the best album artist to use 227 | func getAlbumArtist(track Track, album *Album) string { 228 | if album != nil && album.Artist != "" { 229 | return album.Artist 230 | } 231 | if track.AlbumArtist != "" { 232 | return track.AlbumArtist 233 | } 234 | return track.Artist 235 | } 236 | 237 | // getReleaseDate determines the best release date to use 238 | func getReleaseDate(track Track, album *Album) string { 239 | if track.ReleaseDate != "" { 240 | return track.ReleaseDate 241 | } 242 | if album != nil && album.ReleaseDate != "" { 243 | return album.ReleaseDate 244 | } 245 | return "" 246 | } 247 | 248 | // getGenre determines the best genre to use 249 | func getGenre(track Track, album *Album) string { 250 | if track.Genre != "" && track.Genre != "Unknown" { 251 | return track.Genre 252 | } 253 | if album != nil && album.Genre != "" && album.Genre != "Unknown" { 254 | return album.Genre 255 | } 256 | return "" 257 | } 258 | 259 | // addMusicBrainzMetadata handles optimized MusicBrainz metadata fetching with caching 260 | func addMusicBrainzMetadata(comment *flacvorbis.MetaDataBlockVorbisComment, track Track, album *Album, albumTitle string, warningCollector *WarningCollector) { 261 | // Fetch track-specific metadata 262 | mbTrack, err := mbClient.SearchTrack(track.Artist, albumTitle, track.Title) 263 | if err != nil { 264 | if warningCollector != nil { 265 | warningCollector.AddMusicBrainzTrackWarning(track.Artist, track.Title, err.Error()) 266 | } 267 | } else { 268 | addField(comment, "MUSICBRAINZ_TRACKID", mbTrack.ID) 269 | if len(mbTrack.ArtistCredit) > 0 { 270 | addField(comment, "MUSICBRAINZ_ARTISTID", mbTrack.ArtistCredit[0].Artist.ID) 271 | } 272 | } 273 | 274 | // Handle release-level metadata with caching 275 | if album != nil { 276 | addReleaseMetadata(comment, album.Artist, album.Title, warningCollector) 277 | } 278 | } 279 | 280 | // addReleaseMetadata handles release-level MusicBrainz metadata with caching and retry logic 281 | func addReleaseMetadata(comment *flacvorbis.MetaDataBlockVorbisComment, artist, albumTitle string, warningCollector *WarningCollector) { 282 | // Check cache first 283 | mbRelease := albumCache.GetCachedRelease(artist, albumTitle) 284 | 285 | if mbRelease == nil { 286 | // Not in cache, fetch from MusicBrainz 287 | var err error 288 | mbRelease, err = mbClient.SearchRelease(artist, albumTitle) 289 | if err != nil { 290 | if warningCollector != nil { 291 | warningCollector.AddMusicBrainzReleaseWarning(artist, albumTitle, err.Error()) 292 | } 293 | return 294 | } 295 | 296 | // Cache the successful result 297 | albumCache.SetCachedRelease(artist, albumTitle, mbRelease) 298 | 299 | // Clear any previous warnings for this release since we now have the metadata 300 | if warningCollector != nil { 301 | warningCollector.RemoveMusicBrainzReleaseWarning(artist, albumTitle) 302 | } 303 | } 304 | 305 | // Add release-level metadata fields 306 | addField(comment, "MUSICBRAINZ_ALBUMID", mbRelease.ID) 307 | if len(mbRelease.ArtistCredit) > 0 { 308 | addField(comment, "MUSICBRAINZ_ALBUMARTISTID", mbRelease.ArtistCredit[0].Artist.ID) 309 | } 310 | if mbRelease.ReleaseGroup.ID != "" { 311 | addField(comment, "MUSICBRAINZ_RELEASEGROUPID", mbRelease.ReleaseGroup.ID) 312 | } 313 | } 314 | 315 | // addCoverArt adds cover art to the FLAC file 316 | func addCoverArt(f *flac.File, coverData []byte) error { 317 | if coverData == nil || len(coverData) == 0 { 318 | return nil 319 | } 320 | 321 | // Determine image format 322 | imageFormat := detectImageFormat(coverData) 323 | 324 | // Try to create front cover first 325 | picture, err := flacpicture.NewFromImageData( 326 | flacpicture.PictureTypeFrontCover, 327 | "Front Cover", 328 | coverData, 329 | imageFormat, 330 | ) 331 | if err != nil { 332 | // If front cover fails, try as generic picture 333 | picture, err = flacpicture.NewFromImageData( 334 | flacpicture.PictureTypeOther, 335 | "Cover Art", 336 | coverData, 337 | imageFormat, 338 | ) 339 | if err != nil { 340 | return fmt.Errorf("failed to create picture metadata: %w", err) 341 | } 342 | } 343 | 344 | pictureBlock := picture.Marshal() 345 | f.Meta = append(f.Meta, &pictureBlock) 346 | 347 | return nil 348 | } 349 | 350 | // detectImageFormat detects the image format from the data 351 | func detectImageFormat(data []byte) string { 352 | if len(data) < 4 { 353 | return "image/jpeg" // Default fallback 354 | } 355 | 356 | // Check for PNG signature (89 50 4E 47) 357 | if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 { 358 | return "image/png" 359 | } 360 | 361 | // Check for JPEG signature (FF D8) 362 | if data[0] == 0xFF && data[1] == 0xD8 { 363 | return "image/jpeg" 364 | } 365 | 366 | // Check for WebP signature (RIFF...WEBP) 367 | if len(data) >= 12 && data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 && 368 | data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50 { 369 | return "image/webp" 370 | } 371 | 372 | // Check for GIF signature (GIF8) 373 | if len(data) >= 4 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x38 { 374 | return "image/gif" 375 | } 376 | 377 | // Default to JPEG if we can't determine 378 | return "image/jpeg" 379 | } 380 | // GetCacheStats returns statistics about the current cache state 381 | func GetCacheStats() (int, []string) { 382 | albumCache.mu.RLock() 383 | defer albumCache.mu.RUnlock() 384 | 385 | count := len(albumCache.releases) 386 | var keys []string 387 | for key := range albumCache.releases { 388 | keys = append(keys, key) 389 | } 390 | return count, keys 391 | } 392 | 393 | // ClearAlbumCache clears the global album metadata cache 394 | func ClearAlbumCache() { 395 | albumCache.ClearCache() 396 | } -------------------------------------------------------------------------------- /api.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "strconv" 11 | "strings" 12 | "sync" 13 | "time" // Add time import 14 | 15 | "golang.org/x/sync/semaphore" 16 | ) 17 | 18 | const requestInterval = 500 * time.Millisecond // Define rate limit interval 19 | 20 | // NewDabAPI creates a new API client 21 | func NewDabAPI(endpoint, outputLocation string, client *http.Client) *DabAPI { 22 | return &DabAPI{ 23 | endpoint: strings.TrimSuffix(endpoint, "/"), 24 | outputLocation: outputLocation, 25 | client: client, 26 | rateLimiter: time.NewTicker(requestInterval), // Initialize rate limiter 27 | } 28 | } 29 | 30 | type DabAPI struct { 31 | endpoint string 32 | outputLocation string 33 | client *http.Client 34 | mu sync.Mutex // Mutex to protect rate limiter 35 | rateLimiter *time.Ticker // Rate limiter for API requests 36 | } 37 | 38 | // Request makes HTTP requests to the API 39 | func (api *DabAPI) Request(ctx context.Context, path string, isPathOnly bool, params []QueryParam) (*http.Response, error) { 40 | api.mu.Lock() 41 | <-api.rateLimiter.C // Wait for the rate limiter 42 | api.mu.Unlock() 43 | 44 | var fullURL string 45 | 46 | if isPathOnly { 47 | fullURL = fmt.Sprintf("%s/%s", api.endpoint, strings.TrimPrefix(path, "/")) 48 | } else { 49 | fullURL = path 50 | } 51 | 52 | u, err := url.Parse(fullURL) 53 | if err != nil { 54 | return nil, fmt.Errorf("error parsing URL: %w", err) 55 | } 56 | 57 | if len(params) > 0 { 58 | q := u.Query() 59 | for _, param := range params { 60 | q.Add(param.Name, param.Value) 61 | } 62 | u.RawQuery = q.Encode() 63 | } 64 | 65 | var resp *http.Response 66 | err = RetryWithBackoff(defaultMaxRetries, 1, func() error { 67 | req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil) 68 | if err != nil { 69 | return fmt.Errorf("error creating request: %w", err) 70 | } 71 | req.Header.Set("User-Agent", userAgent) 72 | 73 | resp, err = api.client.Do(req) 74 | if err != nil { 75 | return fmt.Errorf("error executing request: %w", err) 76 | } 77 | 78 | if resp.StatusCode == http.StatusTooManyRequests { 79 | resp.Body.Close() 80 | return fmt.Errorf("rate limit exceeded (429), retrying") // Return error to trigger retry 81 | } 82 | if resp.StatusCode != http.StatusOK { 83 | resp.Body.Close() 84 | return fmt.Errorf("request failed with status: %s", resp.Status) 85 | } 86 | return nil 87 | }) 88 | 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | return resp, nil 94 | } 95 | 96 | // GetAlbum retrieves album information 97 | func (api *DabAPI) GetAlbum(ctx context.Context, albumID string) (*Album, error) { 98 | resp, err := api.Request(ctx, "api/album", true, []QueryParam{ 99 | {Name: "albumId", Value: albumID}, 100 | }) 101 | if err != nil { 102 | return nil, fmt.Errorf("failed to get album: %w", err) 103 | } 104 | defer resp.Body.Close() 105 | 106 | var albumResp AlbumResponse 107 | if err := json.NewDecoder(resp.Body).Decode(&albumResp); err != nil { 108 | return nil, fmt.Errorf("failed to decode album response: %w", err) 109 | } 110 | 111 | // Process tracks to add missing metadata 112 | for i := range albumResp.Album.Tracks { 113 | track := &albumResp.Album.Tracks[i] 114 | 115 | // Set album information if missing 116 | if track.Album == "" { 117 | track.Album = albumResp.Album.Title 118 | } 119 | if track.AlbumArtist == "" { 120 | track.AlbumArtist = albumResp.Album.Artist 121 | } 122 | if track.Genre == "" { 123 | track.Genre = albumResp.Album.Genre 124 | } 125 | if track.ReleaseDate == "" { 126 | track.ReleaseDate = albumResp.Album.ReleaseDate 127 | } 128 | if track.Year == "" && len(albumResp.Album.ReleaseDate) >= 4 { 129 | track.Year = albumResp.Album.ReleaseDate[:4] 130 | } 131 | 132 | // Set track number if not provided 133 | if track.TrackNumber == 0 { 134 | track.TrackNumber = i + 1 135 | } 136 | if track.DiscNumber == 0 { 137 | track.DiscNumber = 1 138 | } 139 | } 140 | 141 | // Set album totals if not provided 142 | if albumResp.Album.TotalTracks == 0 { 143 | albumResp.Album.TotalTracks = len(albumResp.Album.Tracks) 144 | } 145 | if albumResp.Album.TotalDiscs == 0 { 146 | albumResp.Album.TotalDiscs = 1 147 | } 148 | if albumResp.Album.Year == "" && len(albumResp.Album.ReleaseDate) >= 4 { 149 | albumResp.Album.Year = albumResp.Album.ReleaseDate[:4] 150 | } 151 | 152 | // Prepend API endpoint to cover URL if it's a relative path 153 | if strings.HasPrefix(albumResp.Album.Cover, "/") { 154 | albumResp.Album.Cover = api.endpoint + albumResp.Album.Cover 155 | } 156 | 157 | return &albumResp.Album, nil 158 | } 159 | 160 | // GetArtist retrieves artist information and discography 161 | func (api *DabAPI) GetArtist(ctx context.Context, artistID string, config *Config, debug bool) (*Artist, error) { 162 | if debug { 163 | fmt.Printf("DEBUG - GetArtist called with artistID: '%s'\n", artistID) 164 | } 165 | 166 | resp, err := api.Request(ctx, "api/discography", true, []QueryParam{ 167 | {Name: "artistId", Value: artistID}, 168 | }) 169 | if err != nil { 170 | if debug { 171 | fmt.Printf("DEBUG - GetArtist API request failed: %v\n", err) 172 | } 173 | return nil, fmt.Errorf("failed to get artist: %w", err) 174 | } 175 | defer resp.Body.Close() 176 | 177 | body, err := io.ReadAll(resp.Body) 178 | if err != nil { 179 | if debug { 180 | fmt.Printf("DEBUG - GetArtist failed to read response body: %v\n", err) 181 | } 182 | return nil, fmt.Errorf("failed to read response body: %w", err) 183 | } 184 | 185 | if debug { 186 | // Debug: Print the raw JSON response 187 | fmt.Printf("DEBUG - Raw artist response body length: %d bytes\n", len(body)) 188 | fmt.Printf("DEBUG - Raw artist response: %s\n", string(body)) 189 | } 190 | 191 | // The discography endpoint returns a different structure 192 | var discographyResp struct { 193 | Artist Artist `json:"artist"` 194 | Albums []Album `json:"albums"` 195 | } 196 | 197 | if err := json.Unmarshal(body, &discographyResp); err != nil { 198 | if debug { 199 | fmt.Printf("DEBUG - GetArtist JSON unmarshal failed: %v\n", err) 200 | } 201 | return nil, fmt.Errorf("failed to decode artist response: %w", err) 202 | } 203 | 204 | // Combine the artist info with the albums 205 | artist := discographyResp.Artist 206 | artist.Albums = discographyResp.Albums 207 | 208 | // Prioritize artist name from albums if the API returns "Unknown Artist" 209 | if artist.Name == "Unknown Artist" && len(artist.Albums) > 0 { 210 | artist.Name = artist.Albums[0].Artist 211 | } else if artist.Name == "" && len(artist.Albums) > 0 { // Keep existing logic for truly empty name 212 | artist.Name = artist.Albums[0].Artist 213 | } 214 | 215 | if debug { 216 | fmt.Printf("DEBUG - Successfully parsed artist: '%s' with %d albums\n", artist.Name, len(artist.Albums)) 217 | } 218 | 219 | // Process albums to ensure proper categorization 220 | colorInfo.Println("🔍 Fetching detailed album information...") 221 | 222 | var wg sync.WaitGroup 223 | sem := semaphore.NewWeighted(int64(config.Parallelism)) // Use configured parallelism for fetching 224 | 225 | for i := range artist.Albums { 226 | wg.Add(1) 227 | album := &artist.Albums[i] // Capture album for goroutine 228 | 229 | go func(album *Album) { 230 | defer wg.Done() 231 | if err := sem.Acquire(ctx, 1); err != nil { 232 | colorError.Printf("Failed to acquire semaphore for album %s: %v\n", album.Title, err) 233 | return 234 | } 235 | defer sem.Release(1) 236 | 237 | // If album type is not provided by the discography endpoint, fetch full album details 238 | if album.Type == "" || len(album.Tracks) == 0 { 239 | colorInfo.Printf(" Fetching details for album: %s (ID: %s)\n", album.Title, album.ID) 240 | if debug { 241 | fmt.Printf("DEBUG - Fetching full album details for album ID: %s, Title: %s\n", album.ID, album.Title) 242 | } 243 | fullAlbum, err := api.GetAlbum(ctx, album.ID) 244 | if err != nil { 245 | if debug { 246 | fmt.Printf("DEBUG - Failed to fetch full album details for %s: %v\n", album.Title, err) 247 | } 248 | // Continue with heuristic if fetching full album fails 249 | } else { 250 | // Update album with full details 251 | album.Type = fullAlbum.Type 252 | album.Tracks = fullAlbum.Tracks 253 | album.TotalTracks = fullAlbum.TotalTracks 254 | album.TotalDiscs = fullAlbum.TotalDiscs 255 | album.Year = fullAlbum.Year 256 | } 257 | } 258 | 259 | // Auto-detect type if still not provided or tracks were empty 260 | if album.Type == "" { 261 | trackCount := len(album.Tracks) 262 | if trackCount == 0 { 263 | album.Type = "album" // Default assumption if no track info 264 | } else if trackCount == 1 { 265 | album.Type = "single" 266 | } else if trackCount <= 6 { 267 | album.Type = "ep" 268 | } else { 269 | album.Type = "album" 270 | } 271 | } 272 | 273 | // Normalize type to lowercase for consistency 274 | album.Type = strings.ToLower(album.Type) 275 | 276 | // Set year if missing 277 | if album.Year == "" && len(album.ReleaseDate) >= 4 { 278 | album.Year = album.ReleaseDate[:4] 279 | } 280 | }(album) 281 | } 282 | wg.Wait() 283 | 284 | return &artist, nil 285 | } 286 | 287 | // GetTrack retrieves track information 288 | func (api *DabAPI) GetTrack(ctx context.Context, trackID string) (*Track, error) { 289 | resp, err := api.Request(ctx, "api/track", true, []QueryParam{ 290 | {Name: "trackId", Value: trackID}, 291 | }) 292 | if err != nil { 293 | return nil, fmt.Errorf("failed to get track: %w", err) 294 | } 295 | defer resp.Body.Close() 296 | 297 | var trackResp TrackResponse 298 | if err := json.NewDecoder(resp.Body).Decode(&trackResp); err != nil { 299 | return nil, fmt.Errorf("failed to decode track response: %w", err) 300 | } 301 | 302 | // Set missing metadata defaults 303 | track := &trackResp.Track 304 | if track.TrackNumber == 0 { 305 | track.TrackNumber = 1 306 | } 307 | if track.DiscNumber == 0 { 308 | track.DiscNumber = 1 309 | } 310 | if track.Year == "" && len(track.ReleaseDate) >= 4 { 311 | track.Year = track.ReleaseDate[:4] 312 | } 313 | 314 | return track, nil 315 | } 316 | 317 | // GetStreamURL retrieves the stream URL for a track 318 | func (api *DabAPI) GetStreamURL(ctx context.Context, trackID string) (string, error) { 319 | var streamURL StreamURL 320 | err := RetryWithBackoff(defaultMaxRetries, 1, func() error { 321 | resp, err := api.Request(ctx, "api/stream", true, []QueryParam{ 322 | {Name: "trackId", Value: trackID}, 323 | {Name: "quality", Value: "27"}, // Highest quality FLAC 324 | }) 325 | if err != nil { 326 | return fmt.Errorf("failed to get stream URL: %w", err) 327 | } 328 | defer resp.Body.Close() 329 | 330 | if err := json.NewDecoder(resp.Body).Decode(&streamURL); err != nil { 331 | return fmt.Errorf("failed to decode stream URL: %w", err) 332 | } 333 | return nil 334 | }) 335 | if err != nil { 336 | return "", err 337 | } 338 | 339 | return streamURL.URL, nil 340 | } 341 | 342 | // DownloadCover downloads cover art 343 | func (api *DabAPI) DownloadCover(ctx context.Context, coverURL string) ([]byte, error) { 344 | var coverData []byte 345 | err := RetryWithBackoff(defaultMaxRetries, 1, func() error { 346 | resp, err := api.Request(ctx, coverURL, false, nil) 347 | if err != nil { 348 | return err 349 | } 350 | defer resp.Body.Close() 351 | 352 | coverData, err = io.ReadAll(resp.Body) 353 | return err 354 | }) 355 | return coverData, err 356 | } 357 | 358 | 359 | // Search searches for artists, albums, or tracks. 360 | func (api *DabAPI) Search(ctx context.Context, query string, searchType string, limit int, debug bool) (*SearchResults, error) { 361 | results := &SearchResults{} 362 | var wg sync.WaitGroup 363 | var mu sync.Mutex 364 | errChan := make(chan error, 3) 365 | 366 | searchTypes := []string{} 367 | if searchType == "all" { 368 | searchTypes = []string{"artist", "album", "track"} 369 | } else { 370 | searchTypes = []string{searchType} 371 | } 372 | 373 | for _, t := range searchTypes { 374 | wg.Add(1) 375 | go func(t string) { 376 | defer wg.Done() 377 | params := []QueryParam{ 378 | {Name: "q", Value: query}, 379 | {Name: "type", Value: t}, 380 | {Name: "limit", Value: strconv.Itoa(limit)}, 381 | } 382 | resp, err := api.Request(ctx, "api/search", true, params) 383 | if err != nil { 384 | errChan <- err 385 | return 386 | } 387 | defer resp.Body.Close() 388 | 389 | body, err := io.ReadAll(resp.Body) 390 | if err != nil { 391 | errChan <- err 392 | return 393 | } 394 | 395 | mu.Lock() 396 | defer mu.Unlock() 397 | 398 | if debug { 399 | fmt.Printf("DEBUG - Raw search response body: %s\n", string(body)) 400 | } 401 | var data map[string]json.RawMessage 402 | if err := json.Unmarshal(body, &data); err != nil { 403 | fmt.Printf("ERROR: Failed to unmarshal JSON. Raw response body: %s\n", string(body)) 404 | errChan <- err 405 | return 406 | } 407 | 408 | switch t { 409 | case "artist": 410 | if res, ok := data["artists"]; ok { 411 | if err := json.Unmarshal(res, &results.Artists); err != nil { 412 | errChan <- err 413 | } 414 | } else if res, ok := data["tracks"]; ok { 415 | var tempTracks []Track 416 | if err := json.Unmarshal(res, &tempTracks); err != nil { 417 | errChan <- err 418 | return 419 | } 420 | uniqueArtists := make(map[string]Artist) 421 | for _, track := range tempTracks { 422 | artist := Artist{ 423 | ID: track.ArtistId, 424 | Name: track.Artist, 425 | } 426 | uniqueArtists[fmt.Sprintf("%v", artist.ID)] = artist // Use artist ID as key for uniqueness 427 | } 428 | for _, artist := range uniqueArtists { 429 | results.Artists = append(results.Artists, artist) 430 | } 431 | } else if res, ok := data["results"]; ok { 432 | if err := json.Unmarshal(res, &results.Artists); err != nil { 433 | errChan <- err 434 | } 435 | } 436 | case "album": 437 | if res, ok := data["albums"]; ok { 438 | if err := json.Unmarshal(res, &results.Albums); err != nil { 439 | errChan <- err 440 | } 441 | } else if res, ok := data["results"]; ok { 442 | if err := json.Unmarshal(res, &results.Albums); err != nil { 443 | errChan <- err 444 | } 445 | } 446 | case "track": 447 | if res, ok := data["tracks"]; ok { 448 | if err := json.Unmarshal(res, &results.Tracks); err != nil { 449 | errChan <- err 450 | } 451 | } else if res, ok := data["results"]; ok { 452 | if err := json.Unmarshal(res, &results.Tracks); err != nil { 453 | errChan <- err 454 | } 455 | } 456 | } 457 | }(t) 458 | } 459 | 460 | wg.Wait() 461 | close(errChan) 462 | 463 | for err := range errChan { 464 | if err != nil { 465 | // For now, just return the first error 466 | return nil, err 467 | } 468 | } 469 | 470 | return results, nil 471 | } -------------------------------------------------------------------------------- /downloader.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "sync" 11 | 12 | "github.com/cheggaaa/pb/v3" 13 | "github.com/go-flac/go-flac" 14 | "github.com/go-flac/flacvorbis" 15 | "golang.org/x/sync/semaphore" 16 | ) 17 | 18 | // DownloadTrack downloads a single track with metadata 19 | func (api *DabAPI) DownloadTrack(ctx context.Context, track Track, album *Album, outputPath string, coverData []byte, bar *pb.ProgressBar, debug bool, format string, bitrate string, config *Config, warningCollector *WarningCollector) (string, error) { 20 | // Get stream URL 21 | streamURL, err := api.GetStreamURL(ctx, idToString(track.ID)) 22 | if err != nil { 23 | return "", fmt.Errorf("failed to get stream URL: %w", err) 24 | } 25 | 26 | var expectedFileSize int64 // Store expected size for final verification 27 | 28 | // Determine retry attempts 29 | maxRetries := defaultMaxRetries 30 | if config != nil && config.MaxRetryAttempts > 0 { 31 | maxRetries = config.MaxRetryAttempts 32 | } 33 | 34 | // Download the audio file 35 | err = RetryWithBackoff(maxRetries, 5, func() error { 36 | audioResp, err := api.Request(ctx, streamURL, false, nil) 37 | if err != nil { 38 | return fmt.Errorf("failed to download audio: %w", err) 39 | } 40 | defer audioResp.Body.Close() 41 | 42 | expectedSize := audioResp.ContentLength 43 | expectedFileSize = expectedSize // Store for final verification 44 | if debug && expectedSize > 0 { 45 | fmt.Printf("DEBUG: Expected file size for %s: %d bytes\n", track.Title, expectedSize) 46 | } 47 | 48 | // Wrap the response body in the progress bar reader 49 | if bar != nil { 50 | if debug { 51 | fmt.Println("DEBUG: Starting progress bar for", track.Title) 52 | } 53 | if audioResp.ContentLength <= 0 { 54 | bar.Set("indeterminate", true) // Force spinner for unknown size 55 | } else { 56 | bar.SetTotal(audioResp.ContentLength) 57 | } 58 | audioResp.Body = bar.NewProxyReader(audioResp.Body) 59 | } 60 | 61 | // Create directory if needed 62 | if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil { 63 | return fmt.Errorf("failed to create directory: %w", err) 64 | } 65 | 66 | // Create and write to the output file 67 | out, err := os.Create(outputPath) 68 | if err != nil { 69 | return fmt.Errorf("failed to create output file: %w", err) 70 | } 71 | defer out.Close() 72 | 73 | bytesWritten, err := io.Copy(out, audioResp.Body) 74 | if err != nil { 75 | // Clean up the file on error to prevent partial files 76 | os.Remove(outputPath) 77 | return fmt.Errorf("failed to write audio file: %w", err) 78 | } 79 | 80 | // Verify file size if ContentLength is available 81 | if expectedSize > 0 && bytesWritten != expectedSize { 82 | // Clean up the incomplete file 83 | os.Remove(outputPath) 84 | if debug { 85 | fmt.Printf("DEBUG: File size mismatch for %s - expected: %d, got: %d bytes\n", 86 | track.Title, expectedSize, bytesWritten) 87 | } 88 | return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, bytesWritten) 89 | } 90 | 91 | if debug && expectedSize > 0 { 92 | fmt.Printf("DEBUG: Successfully downloaded %s - %d bytes verified\n", track.Title, bytesWritten) 93 | } 94 | 95 | return nil 96 | }) 97 | if err != nil { 98 | return "", err 99 | } 100 | 101 | // Final verification: check if the file exists and has the correct size 102 | // This catches any issues that might occur after the download completes 103 | if FileExists(outputPath) { 104 | // Only verify if verification is enabled (default true if not specified) 105 | verifyEnabled := config == nil || config.VerifyDownloads // Default to true 106 | if verifyEnabled && expectedFileSize > 0 { 107 | if verifyErr := VerifyFileIntegrity(outputPath, expectedFileSize, debug); verifyErr != nil { 108 | // Remove the corrupted file and return error 109 | os.Remove(outputPath) 110 | return "", fmt.Errorf("post-download verification failed: %w", verifyErr) 111 | } 112 | } 113 | } else { 114 | return "", fmt.Errorf("download completed but file not found on disk: %s", outputPath) 115 | } 116 | 117 | // Add metadata to the downloaded file 118 | err = AddMetadataWithDebug(outputPath, track, album, coverData, len(album.Tracks), warningCollector, debug) 119 | if err != nil { 120 | return "", fmt.Errorf("failed to add metadata: %w", err) 121 | } 122 | 123 | finalPath := outputPath 124 | if format != "flac" { 125 | colorInfo.Printf("🎵 Compressing to %s with bitrate %s kbps...\n", format, bitrate) 126 | convertedFile, err := ConvertTrack(outputPath, format, bitrate) 127 | if err != nil { 128 | return "", fmt.Errorf("failed to convert track: %w", err) 129 | } 130 | // Conversion successful, remove original FLAC file 131 | if err := os.Remove(outputPath); err != nil { 132 | colorWarning.Printf("⚠️ Failed to remove original FLAC file: %v\n", err) 133 | } 134 | finalPath = convertedFile 135 | if debug { 136 | colorInfo.Printf("✅ Successfully converted to %s: %s\n", format, convertedFile) 137 | } 138 | } 139 | 140 | return finalPath, nil 141 | } 142 | 143 | // DownloadSingleTrack downloads a single track. 144 | // It now accepts a full Track object, assuming it comes from search results. 145 | func (api *DabAPI) DownloadSingleTrack(ctx context.Context, track Track, debug bool, format string, bitrate string, pool *pb.Pool, config *Config, warningCollector *WarningCollector) error { 146 | // Create warning collector if not provided (standalone track download) 147 | var ownCollector bool 148 | if warningCollector == nil { 149 | warningCollector = NewWarningCollector(config.WarningBehavior != "silent") 150 | ownCollector = true 151 | } 152 | colorInfo.Printf("🎶 Preparing to download track: %s by %s (Album ID: %s)...\n", track.Title, track.Artist, track.AlbumID) 153 | 154 | colorInfo.Printf("🎶 Preparing to download track: %s by %s (Album ID: %s)...\n", track.Title, track.Artist, track.AlbumID) 155 | 156 | // Fetch the album information using the track's AlbumID 157 | album, err := api.GetAlbum(ctx, track.AlbumID) 158 | if err != nil { 159 | if config.WarningBehavior == "immediate" { 160 | colorWarning.Printf("⚠️ Could not fetch album info for track %s (ID: %s): %v. Attempting to proceed with limited album info.\n", track.Title, idToString(track.ID), err) 161 | } else { 162 | warningCollector.AddAlbumFetchWarning(track.Title, idToString(track.ID), err.Error()) 163 | } 164 | // Create a minimal album object if fetching fails, to allow metadata to be added 165 | album = &Album{Title: track.Album, Artist: track.Artist, Tracks: []Track{track}} 166 | } 167 | 168 | // Find the specific track within the fetched album's tracks. 169 | // This is important because the 'track' object passed in might not have all details 170 | // that the full album fetch provides (e.g., full cover URL, stream URL details). 171 | var albumTrack *Track 172 | for i := range album.Tracks { 173 | if idToString(album.Tracks[i].ID) == idToString(track.ID) { 174 | albumTrack = &album.Tracks[i] 175 | break 176 | } 177 | } 178 | 179 | if albumTrack == nil { 180 | return fmt.Errorf("failed to find track %s (ID: %s) within its album %s (ID: %s)", track.Title, idToString(track.ID), album.Title, album.ID) 181 | } 182 | 183 | // Download cover 184 | var coverData []byte 185 | if album.Cover != "" { 186 | coverData, err = api.DownloadCover(ctx, album.Cover) 187 | if err != nil { 188 | if config.WarningBehavior == "immediate" { 189 | colorWarning.Printf("⚠️ Could not download cover art for album %s: %v\n", album.Title, err) 190 | } else { 191 | warningCollector.AddCoverArtDownloadWarning(album.Title, err.Error()) 192 | } 193 | } 194 | } 195 | 196 | // Create track path 197 | artistDir := filepath.Join(api.outputLocation, SanitizeFileName(albumTrack.Artist)) 198 | albumDir := filepath.Join(artistDir, SanitizeFileName(album.Title)) 199 | trackFileName := GetTrackFilename(albumTrack.TrackNumber, albumTrack.Title) 200 | trackPath := filepath.Join(albumDir, trackFileName) 201 | 202 | // Skip if already exists 203 | if FileExists(trackPath) { 204 | if config.WarningBehavior == "immediate" { 205 | colorWarning.Printf("⭐ Track already exists: %s\n", trackPath) 206 | } else { 207 | warningCollector.AddTrackSkippedWarning(trackPath) 208 | } 209 | return nil 210 | } 211 | 212 | // Create progress bar 213 | var bar *pb.ProgressBar 214 | if pool != nil { // Use pool if provided 215 | bar = pb.New(0) 216 | bar.SetTemplateString(`{{ string . "prefix" }} {{ bar . }} {{ percent . }} | {{ speed . "%s/s" }} | ETA {{ rtime . "%s" }}`) 217 | bar.Set("prefix", fmt.Sprintf("Downloading %-40s: ", TruncateString(albumTrack.Title, 40))) 218 | if debug { 219 | fmt.Println("DEBUG: Creating single track progress bar for", albumTrack.Title) 220 | } 221 | pool.Add(bar) // Add to pool 222 | } else if isTTY() { // Fallback to single bar if no pool and is TTY 223 | bar = pb.New(0) 224 | bar.SetWriter(os.Stdout) 225 | bar.SetTemplateString(`{{ string . "prefix" }} {{ bar . }} {{ percent . }} | {{ speed . "%s/s" }} | ETA {{ rtime . "%s" }}`) 226 | bar.Set("prefix", fmt.Sprintf("Downloading %-40s: ", TruncateString(albumTrack.Title, 40))) 227 | if debug { 228 | fmt.Println("DEBUG: Creating single track progress bar for", albumTrack.Title) 229 | } 230 | bar.Start() 231 | } 232 | 233 | // Download the track 234 | finalPath, err := api.DownloadTrack(ctx, *albumTrack, album, trackPath, coverData, bar, debug, format, bitrate, config, warningCollector) 235 | if err != nil { 236 | if bar != nil && pool == nil { // Only finish if it's a standalone bar 237 | bar.Finish() 238 | } 239 | return err 240 | } 241 | if bar != nil && pool == nil { // Only finish if it's a standalone bar 242 | bar.Finish() 243 | } 244 | 245 | colorSuccess.Printf("✅ Successfully downloaded: %s\n", finalPath) 246 | 247 | // Show warning summary only if we own the collector (standalone download) 248 | if ownCollector && config.WarningBehavior == "summary" { 249 | warningCollector.PrintSummary() 250 | } 251 | 252 | return nil 253 | } 254 | 255 | 256 | // DownloadAlbum downloads all tracks from an album 257 | func (api *DabAPI) DownloadAlbum(ctx context.Context, albumID string, config *Config, debug bool, pool *pb.Pool, warningCollector *WarningCollector) (*DownloadStats, error) { 258 | // Create warning collector if not provided (standalone album download) 259 | var ownCollector bool 260 | if warningCollector == nil { 261 | warningCollector = NewWarningCollector(config.WarningBehavior != "silent") 262 | ownCollector = true 263 | } 264 | 265 | album, err := api.GetAlbum(ctx, albumID) 266 | if err != nil { 267 | return nil, fmt.Errorf("failed to get album info: %w", err) 268 | } 269 | 270 | artistDir := filepath.Join(api.outputLocation, SanitizeFileName(album.Artist)) 271 | albumDir := filepath.Join(artistDir, SanitizeFileName(album.Title)) 272 | 273 | if err := os.MkdirAll(albumDir, 0755); err != nil { 274 | return nil, fmt.Errorf("failed to create album directory: %w", err) 275 | } 276 | 277 | // Download cover 278 | var coverData []byte 279 | if album.Cover != "" { 280 | coverData, err = api.DownloadCover(ctx, album.Cover) 281 | if err != nil { 282 | if config.WarningBehavior == "immediate" { 283 | colorWarning.Printf("⚠️ Could not download cover art for album %s: %v\n", album.Title, err) 284 | } else { 285 | warningCollector.AddCoverArtDownloadWarning(album.Title, err.Error()) 286 | } 287 | } 288 | } 289 | 290 | if config.SaveAlbumArt && coverData != nil { 291 | coverPath := filepath.Join(albumDir, "cover.jpg") 292 | if err := os.WriteFile(coverPath, coverData, 0644); err != nil { 293 | if config.WarningBehavior == "immediate" { 294 | colorWarning.Printf("⚠️ Failed to save cover art for album %s: %v\n", album.Title, err) 295 | } else { 296 | warningCollector.AddCoverArtDownloadWarning(album.Title, fmt.Sprintf("Failed to save: %v", err)) 297 | } 298 | } 299 | } 300 | 301 | // Setup for concurrent downloads 302 | var wg sync.WaitGroup 303 | sem := semaphore.NewWeighted(int64(config.Parallelism)) 304 | stats := &DownloadStats{} 305 | errorChan := make(chan trackError, len(album.Tracks)) 306 | 307 | var localPool bool 308 | if pool == nil && isTTY() { 309 | var err error 310 | pool, err = pb.StartPool() 311 | if err != nil { 312 | colorError.Printf("❌ Failed to start progress bar pool: %v\n", err) 313 | // Continue without the pool 314 | } else { 315 | localPool = true 316 | } 317 | } 318 | 319 | // Create all progress bars first 320 | bars := make([]*pb.ProgressBar, len(album.Tracks)) 321 | if pool != nil { 322 | for i, track := range album.Tracks { 323 | trackNumber := track.TrackNumber 324 | if trackNumber == 0 { 325 | trackNumber = i + 1 326 | } 327 | bar := pb.New(0) 328 | bar.SetTemplateString(`{{ string . "prefix" }} {{ bar . }} {{ percent . }} | {{ speed . "%s/s" }} | ETA {{ rtime . "%s" }}`) 329 | bar.Set("prefix", fmt.Sprintf("Track %-2d: %-40s", trackNumber, TruncateString(track.Title, 40))) 330 | bars[i] = bar 331 | pool.Add(bar) 332 | } 333 | } 334 | 335 | // Loop through tracks and start a goroutine for each download 336 | for idx, track := range album.Tracks { 337 | wg.Add(1) 338 | if err := sem.Acquire(ctx, 1); err != nil { 339 | colorError.Printf("Failed to acquire semaphore: %v\n", err) 340 | wg.Done() 341 | continue 342 | } 343 | 344 | go func(idx int, track Track) { 345 | defer wg.Done() 346 | defer sem.Release(1) 347 | 348 | trackNumber := track.TrackNumber 349 | if trackNumber == 0 { 350 | trackNumber = idx + 1 351 | } 352 | 353 | trackFileName := fmt.Sprintf("%02d - %s.flac", trackNumber, SanitizeFileName(track.Title)) 354 | trackPath := filepath.Join(albumDir, trackFileName) 355 | 356 | // Skip if already exists 357 | if FileExists(trackPath) { 358 | if config.WarningBehavior == "immediate" { 359 | colorWarning.Printf("⭐ Track already exists: %s\n", trackPath) 360 | } else { 361 | warningCollector.AddTrackSkippedWarning(trackPath) 362 | } 363 | stats.SkippedCount++ 364 | return 365 | } 366 | 367 | var bar *pb.ProgressBar 368 | if pool != nil { 369 | bar = bars[idx] 370 | } 371 | 372 | if _, err := api.DownloadTrack(ctx, track, album, trackPath, coverData, bar, debug, config.Format, config.Bitrate, config, warningCollector); err != nil { 373 | errorChan <- trackError{track.Title, fmt.Errorf("track %s: %w", track.Title, err)} 374 | return 375 | } 376 | 377 | stats.SuccessCount++ 378 | 379 | }(idx, track) 380 | } 381 | 382 | // Wait for all downloads to finish 383 | wg.Wait() 384 | if localPool && pool != nil { 385 | pool.Stop() 386 | } 387 | close(errorChan) 388 | 389 | // Collect errors 390 | for err := range errorChan { 391 | stats.FailedCount++ 392 | stats.FailedItems = append(stats.FailedItems, fmt.Sprintf("%s: %v", err.Title, err.Err)) 393 | } 394 | 395 | // After all downloads complete, check if we can retroactively update any failed tracks 396 | // with release metadata that might have been fetched successfully 397 | if album != nil { 398 | updateFailedTracksWithReleaseMetadata(albumDir, album, warningCollector) 399 | } 400 | 401 | // Show warning summary only if we own the collector (standalone download) 402 | if ownCollector && config.WarningBehavior == "summary" { 403 | warningCollector.PrintSummary() 404 | } 405 | 406 | return stats, nil 407 | } 408 | 409 | // updateFailedTracksWithReleaseMetadata retroactively updates FLAC files with release metadata 410 | // when the release metadata was successfully fetched after some tracks had already been processed 411 | func updateFailedTracksWithReleaseMetadata(albumDir string, album *Album, warningCollector *WarningCollector) { 412 | if album == nil { 413 | return 414 | } 415 | 416 | // Check if we have cached release metadata for this album 417 | mbRelease := albumCache.GetCachedRelease(album.Artist, album.Title) 418 | if mbRelease == nil { 419 | return // No release metadata available 420 | } 421 | 422 | // Find all FLAC files in the album directory 423 | files, err := filepath.Glob(filepath.Join(albumDir, "*.flac")) 424 | if err != nil { 425 | return 426 | } 427 | 428 | updatedCount := 0 429 | for _, filePath := range files { 430 | if updateTrackWithReleaseMetadata(filePath, mbRelease, warningCollector) { 431 | updatedCount++ 432 | } 433 | } 434 | 435 | // If we successfully updated any tracks, clear the release warning since we now have the metadata 436 | if updatedCount > 0 && warningCollector != nil { 437 | warningCollector.RemoveMusicBrainzReleaseWarning(album.Artist, album.Title) 438 | } 439 | } 440 | 441 | // updateTrackWithReleaseMetadata updates a single FLAC file with release metadata 442 | // Returns true if the track was successfully updated, false otherwise 443 | func updateTrackWithReleaseMetadata(filePath string, mbRelease *MusicBrainzRelease, warningCollector *WarningCollector) bool { 444 | // Open the FLAC file 445 | f, err := flac.ParseFile(filePath) 446 | if err != nil { 447 | return false // Skip files that can't be parsed 448 | } 449 | 450 | // Find the existing Vorbis comment block 451 | var vorbisBlock *flac.MetaDataBlock 452 | for _, block := range f.Meta { 453 | if block.Type == flac.VorbisComment { 454 | vorbisBlock = block 455 | break 456 | } 457 | } 458 | 459 | if vorbisBlock == nil { 460 | return false // No vorbis comment block found 461 | } 462 | 463 | // Parse the existing vorbis comment 464 | comment, err := flacvorbis.ParseFromMetaDataBlock(*vorbisBlock) 465 | if err != nil { 466 | return false 467 | } 468 | 469 | // Check if release metadata is already present 470 | if hasReleaseMetadata(comment) { 471 | return false // Already has release metadata 472 | } 473 | 474 | // Add the missing release metadata 475 | addField(comment, "MUSICBRAINZ_ALBUMID", mbRelease.ID) 476 | if len(mbRelease.ArtistCredit) > 0 { 477 | addField(comment, "MUSICBRAINZ_ALBUMARTISTID", mbRelease.ArtistCredit[0].Artist.ID) 478 | } 479 | if mbRelease.ReleaseGroup.ID != "" { 480 | addField(comment, "MUSICBRAINZ_RELEASEGROUPID", mbRelease.ReleaseGroup.ID) 481 | } 482 | 483 | // Replace the old vorbis comment block with the updated one 484 | newVorbisBlock := comment.Marshal() 485 | for i, block := range f.Meta { 486 | if block.Type == flac.VorbisComment { 487 | f.Meta[i] = &newVorbisBlock 488 | break 489 | } 490 | } 491 | 492 | // Save the updated file 493 | if err := f.Save(filePath); err != nil { 494 | if warningCollector != nil { 495 | warningCollector.AddCoverArtMetadataWarning(filePath, fmt.Sprintf("Failed to update release metadata: %v", err)) 496 | } 497 | return false 498 | } 499 | 500 | return true // Successfully updated 501 | } 502 | 503 | // hasReleaseMetadata checks if the vorbis comment already contains release metadata 504 | func hasReleaseMetadata(comment *flacvorbis.MetaDataBlockVorbisComment) bool { 505 | // Convert the comment to string and check for MusicBrainz fields 506 | commentStr := string(comment.Marshal().Data) 507 | return strings.Contains(commentStr, "MUSICBRAINZ_ALBUMID") || 508 | strings.Contains(commentStr, "MUSICBRAINZ_ALBUMARTISTID") || 509 | strings.Contains(commentStr, "MUSICBRAINZ_RELEASEGROUPID") 510 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🎵 DAB Music Downloader 2 | 3 | [![Go Version](https://img.shields.io/badge/go-%3E%3D1.19-blue.svg)](https://golang.org/dl/) 4 | [![License](https://img.shields.io/badge/license-Educational-green.svg)](#license) 5 | [![Release](https://img.shields.io/github/v/release/PrathxmOp/dab-downloader)](https://github.com/PrathxmOp/dab-downloader/releases/latest) 6 | [![Discord Support](https://img.shields.io/badge/Support-Discord-blue.svg?logo=discord&logoColor=white)](https://discord.gg/Mj6bSfD2VG) 7 | ![Development Status](https://img.shields.io/badge/status-unstable%20development-orange.svg) 8 | 9 | > A powerful, modular music downloader that delivers high-quality FLAC files with comprehensive metadata support through the DAB API. 10 | 11 | ## Table of Contents 12 | - [⚠️ IMPORTANT: Development Status](#️-important-development-status) 13 | - [✨ Key Features](#-key-features) 14 | - [📸 Screenshots](#-screenshots) 15 | - [🚀 Quick Start](#-quick-start) 16 | - [Option 1: Using `auto-dl.sh` Script (Recommended)](#option-1-using-auto-dlsh-script-recommended) 17 | - [Option 2: Pre-built Binary](#option-2-pre-built-binary) 18 | - [Option 3: Build from Source](#option-3-build-from-source) 19 | - [Option 4: Docker (Containerized)](#option-4-docker-containerized) 20 | - [🔄 CRITICAL: Staying Updated](#-critical-staying-updated) 21 | - [🚨 Daily Update Routine (Recommended)](#-daily-update-routine-recommended) 22 | - [Versioning Format](#versioning-format) 23 | - [Option 1: Pre-built Binary Updates](#option-1-pre-built-binary-updates) 24 | - [Option 2: Source Code Updates](#option-2-source-code-updates) 25 | - [Option 3: Docker Updates](#option-3-docker-updates) 26 | - [🔔 Get Update Notifications](#-get-update-notifications) 27 | - [📋 Usage Guide](#-usage-guide) 28 | - [🔍 Search and Discover](#-search-and-discover) 29 | - [📀 Download Content](#-download-content) 30 | - [🎧 Spotify Integration](#-spotify-integration) 31 | - [🎵 Navidrome Integration](#-navidrome-integration) 32 | - [⚙️ Configuration](#️-configuration) 33 | - [First-Time Setup](#first-time-setup) 34 | - [Configuration File](#configuration-file) 35 | - [⚙️ Command-Line Flags](#️-command-line-flags) 36 | - [Global Flags (Persistent Flags)](#global-flags-persistent-flags) 37 | - [Command-Specific Flags](#command-specific-flags) 38 | - [`album` command](#album-command) 39 | - [`artist` command](#artist-command) 40 | - [`search` command](#search-command) 41 | - [`spotify` command](#spotify-command) 42 | - [`navidrome` command](#navidrome-command) 43 | - [📁 File Organization](#-file-organization) 44 | - [🔧 Advanced Features](#-advanced-features) 45 | - [Debug Tools](#debug-tools) 46 | - [Quality & Metadata](#quality--metadata) 47 | - [🐛 Troubleshooting](#-troubleshooting) 48 | - [💬 Support & Community](#-support--community) 49 | - [🏗️ Project Architecture](#️-project-architecture) 50 | - [🤝 Contributing](#-contributing) 51 | - [Development Areas Needing Help](#development-areas-needing-help) 52 | - [⚖️ Legal Notice](#️-legal-notice) 53 | - [📄 License](#-license) 54 | - [🌟 Support the Project](#-support-the-project) 55 | - [Changelog](#changelog) 56 | - [Update Guide](#update-guide) 57 | 58 | ## ⚠️ **IMPORTANT: Development Status** 59 | 60 | 🚧 **This project is currently in active, unstable development.** 🚧 61 | 62 | - **Frequent Breaking Changes**: Features may work one day and break the next 63 | - **Regular Updates Required**: You'll need to update frequently to get the latest fixes 64 | - **Expect Issues**: Something always seems to break when i fix something else 65 | - **Pre-Stable Release**: We're working toward a stable v1.0, but we're not there yet 66 | 67 | **📢 We strongly recommend:** 68 | - [Discord Support Group](https://discord.gg/q9RnuVza2) for real-time updates 69 | - ✅ Checking for updates daily if you're actively using the tool 70 | - ✅ Being prepared to troubleshoot and report issues 71 | - ✅ Having patience as we work through the bugs 72 | 73 | 💬 **Need Help?** Join our [Discord Support Group](https://discord.gg/q9RnuVza2) for instant community support and the latest stability updates! 74 | 75 | 76 | 77 | ## ✨ Key Features 78 | 79 | 🔍 **Smart Search** - Find artists, albums, and tracks with intelligent filtering 80 | 📦 **Complete Discographies** - Download entire artist catalogs with automatic categorization 81 | 🏷️ **Rich Metadata** - Full tag support including genre, composer, producer, ISRC, and copyright 82 | 🎨 **High-Quality Artwork** - Embedded album covers in original resolution 83 | - **Concurrent Downloads** - Fast parallel processing with real-time progress tracking 84 | - **Intelligent Retry Logic** - Robust error handling for reliable downloads 85 | - **Spotify Integration** - Import and download entire Spotify playlists and albums 86 | - **Format Conversion** - Convert downloaded FLAC files to MP3, OGG, Opus with configurable bitrates (requires FFmpeg) 87 | - **Navidrome Support** - Seamless integration with your music server 88 | - **Customizable Naming** - Define your own file and folder structure with configurable naming masks 89 | 90 | ## 📸 Screenshots 91 | 92 | ![img1](./screenshots/ScreenShot1.png) 93 | ![img1](./screenshots/ScreenShot2.png) 94 | 95 | ## 🚀 Quick Start 96 | 97 | ### Option 1: Using `auto-dl.sh` Script (Recommended) 98 | 99 | This script simplifies the process of downloading and keeping `dab-downloader` updated. It fetches the latest version and places it in your current directory. 100 | 101 | **Direct execution with curl:** 102 | ```bash 103 | curl -fsSL https://raw.githubusercontent.com/PrathxmOp/Support-group-junk/main/Scripts/auto-dl.sh | bash 104 | ``` 105 | 106 | **Alternative methods:** 107 | 108 | **Using wget (if curl is not available):** 109 | ```bash 110 | wget -qO- https://raw.githubusercontent.com/PrathxmOp/Support-group-junk/main/Scripts/auto-dl.sh | bash 111 | ``` 112 | 113 | **Download first, then execute (safer approach):** 114 | ```bash 115 | curl -fsSL -o auto-dl.sh https://raw.githubusercontent.com/PrathxmOp/Support-group-junk/main/Scripts/auto-dl.sh 116 | chmod +x auto-dl.sh 117 | ./auto-dl.sh 118 | ``` 119 | 120 | ### Option 2: Pre-built Binary 121 | 122 | 1. Download the latest release from our [GitHub Releases](https://github.com/PrathxmOp/dab-downloader/releases/latest) 123 | 2. Extract the archive. 124 | 3. Grant execute permissions to the binary: 125 | ```bash 126 | chmod +x ./dab-downloader-linux-arm64 # Or the appropriate binary for your system 127 | ``` 128 | 4. Run the executable: 129 | ```bash 130 | ./dab-downloader-linux-arm64 # Or the appropriate binary for your system 131 | ``` 132 | 5. Follow the interactive setup on first launch 133 | 134 | ### Option 3: Build from Source 135 | 136 | **Prerequisites:** 137 | - Go 1.19 or later ([Download here](https://golang.org/dl/)) 138 | 139 | ```bash 140 | # Clone the repository 141 | git clone https://github.com/PrathxmOp/dab-downloader.git 142 | cd dab-downloader 143 | 144 | # Install dependencies and build 145 | go mod tidy 146 | go build -o dab-downloader 147 | ``` 148 | 149 | ### Option 4: Docker (Containerized) 150 | 151 | To run dab-downloader using a pre-built Docker image from Docker Hub: 152 | 153 | 1. **Ensure Docker is installed:** Follow the official Docker installation guide for your system. 154 | 2. **Configure with Docker Compose:** 155 | * Make sure your `docker-compose.yml` file is configured to use the `prathxm/dab-downloader:latest` image (as updated by the latest changes). 156 | * Create `config` and `music` directories if they don't exist: 157 | ```bash 158 | mkdir -p config music 159 | ``` 160 | * Copy the example configuration: 161 | ```bash 162 | cp config/example-config.json config/config.json 163 | ``` 164 | 3. **Run any command:** 165 | ```bash 166 | docker compose run dab-downloader search "your favorite artist" 167 | ``` 168 | Or, to run in detached mode: 169 | ```bash 170 | docker compose up -d 171 | ``` 172 | 173 | ## 🔄 **CRITICAL: Staying Updated** 174 | 175 | Due to the unstable nature of this project, **regular updates are essential**: 176 | 177 | ### 🚨 **Daily Update Routine (Recommended)** 178 | 179 | Since we're constantly fixing bugs and pushing updates, we recommend checking for updates daily: 180 | 181 | ```bash 182 | # Check for new releases 183 | ./dab-downloader --version 184 | ``` 185 | 186 | ### Versioning Format 187 | 188 | The application uses a versioning format of `vYYYYMMDD-gCOMMIT_HASH` (e.g., `v20250916-g9fb25ac`). This version is embedded into all binaries and Docker images during the build process, ensuring accurate version reporting and update checks. 189 | 190 | 191 | ### Option 1: Pre-built Binary Updates 192 | 193 | 1. **Check Daily:** Visit the [GitHub Releases page](https://github.com/PrathxmOp/dab-downloader/releases/latest) or watch the repository for notifications 194 | 2. **Download:** Get the latest binary for your operating system and architecture 195 | 3. **Replace:** Replace your existing `dab-downloader` executable with the newly downloaded one 196 | 4. **Permissions (Linux/macOS):** If you encounter an "Exec format error" or "Permission denied": 197 | ```bash 198 | chmod +x ./dab-downloader-linux-arm64 # Or the appropriate binary for your system 199 | ``` 200 | 201 | ### Option 2: Source Code Updates 202 | 203 | If you built from source, update frequently: 204 | 205 | 1. **Pull Latest Changes:** 206 | ```bash 207 | git pull origin main 208 | ``` 209 | 2. **Rebuild:** 210 | ```bash 211 | go mod tidy 212 | go build -o dab-downloader 213 | ``` 214 | 215 | ### Option 3: Docker Updates 216 | 217 | For Docker users, pull the latest image from Docker Hub: 218 | 219 | 1. **Pull Latest Image:** 220 | ```bash 221 | docker compose pull 222 | ``` 223 | 2. **Restart Service:** 224 | ```bash 225 | docker compose up -d 226 | ``` 227 | 228 | ### 🔔 **Get Update Notifications** 229 | 230 | - **Watch this repository** on GitHub for release notifications 231 | - **Join our Discord group** for immediate update announcements 232 | - **Enable GitHub notifications** to know when new releases are available 233 | 234 | ## 📋 Usage Guide 235 | 236 | ### 🔍 Search and Discover 237 | 238 | ```bash 239 | # General search 240 | ./dab-downloader search "Arctic Monkeys" 241 | 242 | # Targeted search 243 | ./dab-downloader search "AM" --type=album 244 | ./dab-downloader search "Do I Wanna Know" --type=track 245 | ./dab-downloader search "Alex Turner" --type=artist 246 | ``` 247 | 248 | ### 📀 Download Content 249 | 250 | ```bash 251 | # Download a specific album 252 | ./dab-downloader album 253 | 254 | # Download artist's complete discography 255 | ./dab-downloader artist 256 | 257 | # Download with filters (non-interactive) 258 | ./dab-downloader artist --filter=albums,eps --no-confirm 259 | ``` 260 | 261 | ### 🎧 Spotify Integration 262 | 263 | **Setup:** Get your [Spotify API credentials](https://developer.spotify.com/dashboard/applications) 264 | 265 | ```bash 266 | # Download entire Spotify playlist 267 | ./dab-downloader spotify 268 | 269 | # Download entire Spotify album 270 | ./dab-downloader spotify 271 | 272 | # Expand playlist to download full albums 273 | ./dab-downloader spotify --expand 274 | 275 | # Auto-download (no manual selection) 276 | ./dab-downloader spotify --auto 277 | 278 | # Auto-download expanded albums from a playlist 279 | ./dab-downloader spotify --expand --auto 280 | ``` 281 | 282 | ### 🎵 Navidrome Integration 283 | 284 | ```bash 285 | # Copy Spotify playlist to Navidrome 286 | ./dab-downloader navidrome 287 | 288 | # Add songs to existing playlist 289 | ./dab-downloader add-to-playlist 290 | ``` 291 | 292 | ## ⚙️ Configuration 293 | 294 | ### First-Time Setup 295 | 296 | The application will guide you through initial configuration: 297 | 298 | 1. **DAB API URL** (e.g., `https://dabmusic.xyz`) 299 | 2. **Download Directory** (e.g., `/home/user/Music`) 300 | 3. **Concurrent Downloads** (recommended: `5`) 301 | 302 | ### Configuration File 303 | 304 | The application will create `config/config.json` on first run. 305 | You can also create or modify it manually. 306 | An example configuration is available at `config/example-config.json`. 307 | 308 | ```json 309 | { 310 | "APIURL": "https://your-dab-api-url.com", 311 | "DownloadLocation": "/path/to/your/music/folder", 312 | "Parallelism": 5, 313 | "SpotifyClientID": "YOUR_SPOTIFY_CLIENT_ID", 314 | "SpotifyClientSecret": "YOUR_SPOTIFY_CLIENT_SECRET", 315 | "NavidromeURL": "https://your-navidrome-url.com", 316 | "NavidromeUsername": "your_navidrome_username", 317 | "NavidromePassword": "your_navidrome_password", 318 | "Format": "flac", 319 | "Bitrate": "320", 320 | "saveAlbumArt": false, 321 | "naming": { 322 | "album_folder_mask": "{artist}/{artist} - {album} ({year})", 323 | "ep_folder_mask": "{artist}/EPs/{artist} - {album} ({year})", 324 | "single_folder_mask": "{artist}/Singles/{artist} - {album} ({year})", 325 | "file_mask": "{track_number} - {artist} - {title}" 326 | } 327 | } 328 | ``` 329 | 330 | ## ⚙️ Command-Line Flags 331 | 332 | You can override configuration settings and control application behavior using command-line flags. Flags can be global (persistent) or specific to certain commands. 333 | 334 | ### Global Flags (Persistent Flags) 335 | 336 | These flags can be used with any command. 337 | 338 | - `--api-url `: Specifies the DAB API endpoint to use. 339 | - **Example:** `--api-url https://dab.example.com` 340 | - `--download-location `: Sets the directory where all downloaded music will be saved. 341 | - **Example:** `--download-location /home/user/Music` 342 | - `--debug`: Enables verbose logging for debugging purposes. 343 | - **Example:** `--debug` 344 | - `--insecure`: Skips TLS certificate verification for API connections. Use with caution. 345 | - **Example:** `--insecure` 346 | - `--spotify-client-id `: Your Spotify application Client ID for Spotify integration. 347 | - **Example:** `--spotify-client-id your_spotify_client_id` 348 | - `--spotify-client-secret `: Your Spotify application Client Secret for Spotify integration. 349 | - **Example:** `--spotify-client-secret your_spotify_client_secret` 350 | - `--navidrome-url `: The URL of your Navidrome server for integration. 351 | - **Example:** `--navidrome-url https://navidrome.example.com` 352 | - `--navidrome-username `: Your Navidrome username. 353 | - **Example:** `--navidrome-username admin` 354 | - `--navidrome-password `: Your Navidrome password. 355 | - **Example:** `--navidrome-password your_navidrome_password` 356 | - `--warnings `: Controls how warnings are displayed during downloads. 357 | - **Modes:** `summary` (default), `immediate`, `silent` 358 | - **Example:** `--warnings immediate` for real-time warnings, `--warnings silent` for clean output 359 | 360 | ### Command-Specific Flags 361 | 362 | These flags are only available for their respective commands. 363 | 364 | #### `album` command 365 | 366 | - `--format `: Specifies the output format for downloaded tracks. Requires FFmpeg. 367 | - **Supported formats:** `flac` (default), `mp3`, `ogg`, `opus` 368 | - **Example:** `dab-downloader album --format mp3` 369 | - `--bitrate `: Sets the bitrate for lossy formats (MP3, OGG, Opus). 370 | - **Supported bitrates:** `192`, `256`, `320` (default) 371 | - **Example:** `dab-downloader album --format mp3 --bitrate 256` 372 | 373 | #### `artist` command 374 | 375 | - `--filter `: Filters the types of items to download from an artist's discography. 376 | - **Supported types:** `albums`, `eps`, `singles` (comma-separated) 377 | - **Example:** `dab-downloader artist --filter albums,singles` 378 | - `--no-confirm`: Skips the confirmation prompt before starting downloads. 379 | - **Example:** `dab-downloader artist --no-confirm` 380 | - `--format `: Same as `album` command's `--format`. 381 | - `--bitrate `: Same as `album` command's `--bitrate`. 382 | 383 | #### `search` command 384 | 385 | - `--type `: Specifies the type of content to search for. 386 | - **Supported types:** `artist`, `album`, `track`, `all` (default) 387 | - **Example:** `dab-downloader search "Arctic Monkeys" --type artist` 388 | - `--auto`: Automatically downloads the first search result without prompting for selection. 389 | - **Example:** `dab-downloader search "Do I Wanna Know" --type track --auto` 390 | - `--format `: Same as `album` command's `--format`. 391 | - `--bitrate `: Same as `album` command's `--bitrate`. 392 | 393 | #### `spotify` command 394 | 395 | - `--auto`: Automatically downloads the first matching DAB result for each Spotify track without prompting. 396 | - **Example:** `dab-downloader spotify --auto` 397 | - `--expand`: When downloading a Spotify playlist, this flag will search for and download the full albums for each unique album found in the playlist, instead of individual tracks. 398 | - **Example:** `dab-downloader spotify --expand` 399 | - `--format `: Same as `album` command's `--format`. 400 | - `--bitrate `: Same as `album` command's `--bitrate`. 401 | 402 | #### `navidrome` command 403 | 404 | - `--ignore-suffix `: Specifies a suffix to ignore when searching for tracks on Navidrome. Useful for cleaning up track titles. 405 | - **Example:** `dab-downloader navidrome --ignore-suffix "(Remastered)"` 406 | - `--expand`: When copying a Spotify playlist to Navidrome, this flag will search for and download the full albums for each unique album found in the playlist to your download location, and then attempt to add those tracks to the Navidrome playlist. 407 | - **Example:** `dab-downloader navidrome --expand` 408 | - `--auto`: Automatically selects the first matching DAB result when searching for tracks to add to Navidrome, without prompting. 409 | - **Example:** `dab-downloader navidrome --auto` 410 | 411 | #### `add-to-playlist` command 412 | 413 | - This command takes a playlist ID and one or more song IDs as arguments. 414 | - **Example:** `dab-downloader add-to-playlist ` 415 | 416 | 417 | ## 📁 File Organization 418 | 419 | Your music library will be organized like this: 420 | 421 | ``` 422 | Music/ 423 | ├── Arctic Monkeys/ 424 | │ ├── artist.jpg 425 | │ ├── AM (2013)/ 426 | │ │ ├── cover.jpg 427 | │ │ ├── 01 - Do I Wanna Know.flac 428 | │ │ └── 02 - R U Mine.flac 429 | │ ├── Humbug (2009)/ 430 | │ │ └── ... 431 | │ └── Singles/ 432 | │ └── I Bet You Look Good on the Dancefloor.flac 433 | ``` 434 | 435 | **Note:** You can customize this structure using the `naming` masks in your `config/config.json` file. 436 | 437 | ## 🔧 Advanced Features 438 | 439 | ### Debug Tools 440 | 441 | ```bash 442 | # Test API connectivity 443 | ./dab-downloader debug api-availability 444 | 445 | # Test artist endpoints 446 | ./dab-downloader debug artist-endpoints 447 | 448 | # Comprehensive debugging 449 | ./dab-downloader debug comprehensive-artist-debug 450 | ``` 451 | 452 | ### Quality & Metadata 453 | 454 | - **Audio Format:** FLAC (highest quality available), or converted to MP3/OGG/Opus 455 | - **Metadata Tags:** Title, Artist, Album, Genre, Year, ISRC, Producer, Composer 456 | - **Cover Art:** Original resolution, auto-format detection 457 | - **File Naming:** Consistent, organized structure 458 | 459 | ## 🐛 Troubleshooting 460 | 461 |
462 | Common Issues & Solutions 463 | 464 | **"Something that worked yesterday is broken today"** 465 | - ✅ **First step:** Check for and install the latest update 466 | - ✅ Check the Discord group for known issues 467 | - ✅ Report the issue with your version number 468 | 469 | **"Failed to get album/artist/track"** 470 | - ✅ Update to the latest version first 471 | - ✅ Verify the ID is correct 472 | - ✅ Check internet connection 473 | - ✅ Confirm DAB API accessibility 474 | 475 | **"Failed to create directory"** 476 | - ✅ Check available disk space 477 | - ✅ Verify write permissions 478 | - ✅ Ensure valid file path 479 | 480 | **"Download failed" or timeouts** 481 | - ✅ App auto-retries failed downloads 482 | - ✅ Check connection stability 483 | - ✅ Some tracks may be unavailable 484 | - ✅ Update to latest version if issues persist 485 | 486 | **Progress bars not showing** 487 | - ✅ Run with `--debug` flag 488 | - ✅ Check terminal compatibility 489 | - ✅ Report output when filing issues 490 | 491 | **"It worked fine last week but now nothing works"** 492 | - ✅ This is expected during development - update immediately 493 | - ✅ Join Discord group for real-time fixes 494 | - ✅ Help me by reporting what broke 495 | 496 |
497 | 498 | ## 💬 Support & Community 499 | 500 | Due to the unstable nature of this project and it being a solo-developed tool, community support is essential: 501 | 502 | 📱 **[Discord Support Group](https://discord.gg/q9RnuVza2)** - **HIGHLY RECOMMENDED** 503 | - Get real-time help and updates 504 | - Learn about breaking changes immediately 505 | - Connect with other users experiencing similar issues 506 | - Get notified when critical fixes are released 507 | - Help the solo developer by reporting issues and testing fixes 508 | 509 | 🐛 **[GitHub Issues](https://github.com/PrathxmOp/dab-downloader/issues)** - Report bugs and request features 510 | - Please include your version number and operating system 511 | - Describe what worked before vs. what's broken now 512 | - Check recent issues - your problem might already be reported 513 | - Be patient - I'm one person handling all development and support 514 | 515 | ## 🏗️ Project Architecture 516 | 517 | ``` 518 | dab-downloader/ 519 | ├── main.go # CLI entry point 520 | ├── search.go # Search functionality 521 | ├── api.go # DAB API client 522 | ├── downloader.go # Download engine 523 | ├── artist_downloader.go # Artist catalog handling 524 | ├── metadata.go # FLAC metadata processing 525 | ├── spotify.go # Spotify integration 526 | ├── navidrome.go # Navidrome integration 527 | ├── utils.go # Utility functions 528 | └── docker-compose.yml # Container setup 529 | ``` 530 | 531 | 🤝 Contributing 532 | 533 | I especially welcome contributions during this unstable development phase: 534 | 535 | 1. **🐛 Report bugs** - Even small issues help me stabilize faster 536 | 2. **💡 Test features** - Help me catch breaking changes early 537 | 3. **🔧 Submit PRs** - Fixes for stability issues are prioritized 538 | 4. **📖 Improve docs** - Help other users navigate the instability 539 | 540 | ### Development Areas Needing Help 541 | 542 | - **Stability Testing** - Help me identify what breaks between versions 543 | - **API Client** (`api.go`) - Enhance error handling and resilience 544 | - **Metadata** (`metadata.go`) - Fix edge cases and improve reliability 545 | - **Downloads** (`downloader.go`) - Improve robustness and error recovery 546 | - **Cross-platform Testing** - Help me ensure updates work across different systems 547 | 548 | ## 💖 Contributors 549 | 550 | A huge thank you to all the amazing people who have contributed to this project and helped make it better! Your contributions are greatly appreciated. 551 | 552 | - **[NimbleAINinja](https://github.com/NimbleAINinja)**: For their outstanding work on the warning collector, MusicBrainz optimizations, and retroactive metadata updates. 553 | 554 | If you've contributed to the project and your name is missing, please feel free to add it! 555 | 556 | 557 | 558 | ## ⚖️ Legal Notice 559 | 560 | This software is provided for **educational purposes only**. Users are responsible for: 561 | 562 | - ✅ Complying with all applicable laws 563 | - ✅ Respecting the terms of service of any third-party services used with this tool 564 | - ✅ Only downloading content they legally own or have permission to access 565 | 566 | The developers and contributors of this project do not endorse piracy or any form of copyright infringement. 567 | 568 | ## 📄 License 569 | 570 | This project is open-source and available under the [MIT License](LICENSE). See the [LICENSE](LICENSE) file for more details. 571 | 572 | ## 🌟 Support the Project 573 | 574 | If you find this project useful and want to support its development, here are a few ways you can help: 575 | 576 | - ⭐ Star this repository 577 | - 🐛 Report issues and bugs 578 | - 💡 Suggest new features 579 | - 🤝 Contribute to the codebase 580 | - 💬 Join our [Discord community](https://discord.gg/q9RnuVza2) and help other users 581 | 582 | Your support and feedback are invaluable to the project's growth and improvement! 🙏 583 | 584 | --- 585 | 586 |
587 | Made with ❤️ for music lovers
588 | Download responsibly • Respect artists • Support music

589 | ⚠️ Remember: Update frequently during development! ⚠️ 590 |
591 | 592 | --- 593 | 594 | ## Changelog 595 | 596 | ### Features 597 | - feat: Add --insecure flag to skip TLS verification 598 | - feat(config): Add configurable naming masks 599 | - feat: Enhance MusicBrainz metadata fetching 600 | - feat(downloader): add log message for skipping existing tracks 601 | - feat(navidrome): add --auto flag to navidrome command 602 | - feat: add --expand flag to navidrome command 603 | - feat: Update README with auto-dl.sh as primary quick start option and bump version 604 | - feat: add manual update guide and improve update prompt 605 | - feat: Implement versioning and update mechanism improvements. New Versioning Scheme: Uses version/version.json for manual updates. Semantic Versioning: Uses github.com/hashicorp/go-version for robust comparisons. Configurable Update Repository: Added UpdateRepo option to config.json. Docker-Aware Update Behavior: Prevents browser attempts in headless environments, allows disabling update checks. Improved Version Display: Reads from version/version.json at runtime. Workflow Updates: Removed build-time ldflags, modified Docker build to copy version.json, updated Docker image tagging, removed redundant TAG_NAME generation. Bug Fixes: Corrected fmt.Errorf usage, fixed \n literal display in UI. This significantly enhances the flexibility and reliability of the application's update process. 606 | - feat: Implement robust versioning and Docker integration and fix #15 607 | - feat: Implement playlist expansion to download full albums 608 | - feat: Implement rate limiting, MusicBrainz, enhanced progress, and artist search fix 609 | - feat: Enhance update notification with prompt, browser opening, and README guide 610 | - feat: Implement explicit version command and colored update status 611 | - feat: Add ARM64 build to release workflow, enabling execution on Termux and other ARM-based Linux systems. 612 | - feat: Add diagnostic step to GitHub Release workflow 613 | - feat: Add option to save album art 614 | - feat: Add --ignore-suffix flag to ignore any suffix 615 | - feat: Add format and bitrate options and fix various bugs 616 | - feat: Implement format conversion 617 | - feat: Overhaul README and add Docker support 618 | - feat: Improve user experience and update project metadata 619 | - feat: Automate release creation on push to main 620 | - feat: Add GitHub Actions for releases and update README 621 | - feat: Enhance CLI help, configuration, and Navidrome integration 622 | - feat: Re-implement multi-select for downloads 623 | 624 | ### Fixes 625 | - fix: Correct build errors 626 | - fix: add blank import for embed package in main.go and bump version to 0.0.29-dev 627 | - fix: remove duplicated code in main.go and bump version to 0.0.28-dev 628 | - fix: embed version.json into binary 629 | - fix(downloader): resolve progress bar race condition 630 | - fix(metadata): correct musicbrainz id tagging 631 | - fix(build): embed version at build time and fix progress bar errors 632 | - fix: update link is now fixed 633 | - fix: Deduplicate artist search results 634 | - fix: Correctly display newlines in terminal output and update .gitignore 635 | - fix: Correct GitHub repository name in updater.go 636 | - Fix: Artist search not returning results 637 | - fix: Preserve metadata when converting to other formats 638 | - fix: use cross-platform home directory for default download location 639 | - fix: handle pagination in spotify playlists and create config dir if not exists 640 | - fix: Replace deprecated release actions with softprops/action-gh-release 641 | - fix: Rename macOS executable to dab-downloader-macos-amd64 642 | - fix: Ensure TAG_NAME is correctly formed in workflow 643 | - fix: Create and push Git tag before creating GitHub Release 644 | - fix: Update setup-go action and Go version in workflow 645 | - fix: Add go mod tidy and download to workflow 646 | - fix: Handle numeric artist IDs from API 647 | 648 | ### Chore 649 | - chore: Bump version to 0.9.0-dev 650 | - chore(version): bump version to 0.8.0-dev 651 | - chore(version): bump version to 0.0.32-dev 652 | - chore: bump version to 0.0.27-dev 653 | - chore: bump version to 0.0.26-dev 654 | - chore: Update GitHub Actions workflow for version.json tagging. Version Source: Reads from version/version.json. Release Tagging: Uses version.json for GitHub Releases and Git tags. Docker Image Tagging: Docker images are also tagged with the version from version.json. 655 | 656 | ### Docs 657 | - docs: update README with changelog 658 | - docs: Update README.md for versioning and Docker integration 659 | - docs: Update README with --expand flag usage 660 | - docs: Update README with development status, update guide, and support information 661 | - docs: Add instructions for granting execute permissions to binaries in README 662 | - Docs: Update README release links 663 | - docs: Enhance README with go mod tidy and Spotify downloader details 664 | 665 | ### Refactor 666 | - refactor(navidrome): improve album and track searching to prevent re-downloads 667 | 668 | ### Other 669 | - Added Discord Group Link 670 | - Shooot my config pushed 671 | - Enhancement in README 672 | - Added option to copy playlist from spotify 673 | - initial change 674 | 675 | --- 676 | 677 | ## Update Guide 678 | 679 | The tool has a built-in update checker. If a new version is available, it will prompt you to update and attempt to open the update guide in your browser. 680 | 681 | If the tool fails to open the browser, you can manually update by following these steps: 682 | 683 | 1. **Go to the [GitHub Releases page](https://github.com/PrathxmOp/dab-downloader/releases/latest).** 684 | 2. **Download the latest release** for your operating system and architecture. 685 | 3. **Extract the archive.** 686 | 4. **Replace your existing `dab-downloader` executable** with the newly downloaded one. 687 | 5. **(Linux/macOS only) Grant execute permissions** to the new binary: 688 | 689 | ```bash 690 | chmod +x ./dab-downloader-linux-amd64 691 | ``` 692 | 693 | (Replace `./dab-downloader-linux-amd64` with the actual name of the binary you downloaded). 694 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "os" 10 | "path/filepath" 11 | "strconv" 12 | "strings" 13 | "time" 14 | "crypto/tls" 15 | "net/http" 16 | 17 | "github.com/cheggaaa/pb/v3" 18 | "github.com/fatih/color" 19 | "github.com/spf13/cobra" 20 | ) 21 | 22 | var toolVersion string 23 | const authorName = "Prathxm" 24 | 25 | //go:embed version/version.json 26 | var versionJSON []byte 27 | 28 | var ( 29 | apiURL string 30 | downloadLocation string 31 | debug bool 32 | filter string 33 | noConfirm bool 34 | searchType string 35 | spotifyPlaylist string 36 | spotifyClientID string 37 | spotifyClientSecret string 38 | auto bool 39 | expandPlaylist bool 40 | expandNavidrome bool 41 | navidromeURL string 42 | navidromeUsername string 43 | navidromePassword string 44 | format string = "flac" 45 | bitrate string = "320" 46 | ignoreSuffix string 47 | insecure bool 48 | warningBehavior string = "summary" 49 | ) 50 | 51 | var rootCmd = &cobra.Command{ 52 | Use: "dab-downloader", 53 | Short: "A high-quality FLAC music downloader for the DAB API.", 54 | } 55 | 56 | var artistCmd = &cobra.Command{ 57 | Use: "artist [artist_id]", 58 | Short: "Download an artist's entire discography.", 59 | Args: cobra.ExactArgs(1), 60 | Run: func(cmd *cobra.Command, args []string) { 61 | config, api := initConfigAndAPI() 62 | if config.Format != "flac" && !CheckFFmpeg() { 63 | printInstallInstructions() 64 | return 65 | } 66 | artistID := args[0] 67 | colorInfo.Println("🎵 Starting artist discography download for ID:", artistID) 68 | if err := api.DownloadArtistDiscography(context.Background(), artistID, config, debug, filter, noConfirm); err != nil { 69 | if errors.Is(err, ErrDownloadCancelled) { 70 | colorWarning.Println("⚠️ Discography download cancelled by user.") 71 | } else if errors.Is(err, ErrNoItemsSelected) { 72 | colorWarning.Println("⚠️ No items were selected for download.") 73 | } else { 74 | colorError.Printf("❌ Failed to download discography: %v\n", err) 75 | } 76 | } else { 77 | colorSuccess.Println("✅ Discography download completed!") 78 | } 79 | }, 80 | } 81 | 82 | var albumCmd = &cobra.Command{ 83 | Use: "album [album_id]", 84 | Short: "Download an album by its ID.", 85 | Args: cobra.ExactArgs(1), 86 | Run: func(cmd *cobra.Command, args []string) { 87 | config, api := initConfigAndAPI() 88 | if config.Format != "flac" && !CheckFFmpeg() { 89 | printInstallInstructions() 90 | return 91 | } 92 | albumID := args[0] 93 | colorInfo.Println("🎵 Starting album download for ID:", albumID) 94 | if _, err := api.DownloadAlbum(context.Background(), albumID, config, debug, nil, nil); err != nil { 95 | colorError.Printf("❌ Failed to download album: %v\n", err) 96 | } else { 97 | colorSuccess.Println("✅ Album download completed!") 98 | } 99 | }, 100 | } 101 | 102 | var searchCmd = &cobra.Command{ 103 | Use: "search [query]", 104 | Short: "Search for artists, albums, or tracks.", 105 | Args: cobra.ExactArgs(1), 106 | Example: ` # Search for albums containing \"parat 3\"\n dab-downloader search \"parat 3\" --type album\n\n # Search for artists named \"coldplay\"\n dab-downloader search \"coldplay\" --type artist\n\n # Search for tracks named \"paradise\" and automatically download the first result\n dab-downloader search \"paradise\" --type track --auto`, 107 | Run: func(cmd *cobra.Command, args []string) { 108 | config, api := initConfigAndAPI() // Get config for parallelism 109 | if config.Format != "flac" && !CheckFFmpeg() { 110 | colorError.Println("❌ ffmpeg is not installed or not in your PATH. Please install ffmpeg to use the format conversion feature.") 111 | return 112 | } 113 | query := args[0] 114 | selectedItems, itemTypes, err := handleSearch(context.Background(), api, query, searchType, debug, auto) 115 | if err != nil { 116 | colorError.Printf("❌ Search failed: %v\n", err) 117 | return 118 | } 119 | if len(selectedItems) == 0 { // User quit or no results 120 | return 121 | } 122 | 123 | 124 | // Initialize pool for multiple track downloads 125 | var pool *pb.Pool 126 | var localPool bool 127 | if isTTY() && len(selectedItems) > 1 { // Only create pool if multiple items and TTY 128 | var err error 129 | pool, err = pb.StartPool() 130 | if err != nil { 131 | colorError.Printf("❌ Failed to start progress bar pool: %v\n", err) 132 | // Continue without the pool 133 | } else { 134 | localPool = true 135 | } 136 | } 137 | 138 | for i, selectedItem := range selectedItems { 139 | itemType := itemTypes[i] 140 | switch itemType { 141 | case "artist": 142 | artist := selectedItem.(Artist) 143 | colorInfo.Println("🎵 Starting artist discography download for:", artist.Name) 144 | artistIDStr := idToString(artist.ID) // Convert ID to string using idToString 145 | if debug { // Add this debug print 146 | colorInfo.Printf("DEBUG - Passing artistIDStr to DownloadArtistDiscography: '%s'\n", artistIDStr) 147 | } 148 | if err := api.DownloadArtistDiscography(context.Background(), artistIDStr, config, debug, filter, noConfirm); err != nil { 149 | colorError.Printf("❌ Failed to download discography for %s: %v\n", artist.Name, err) 150 | } else { 151 | colorSuccess.Println("✅ Discography download completed for", artist.Name) 152 | } 153 | case "album": 154 | album := selectedItem.(Album) 155 | colorInfo.Println("🎵 Starting album download for:", album.Title, "by", album.Artist) 156 | if _, err := api.DownloadAlbum(context.Background(), album.ID, config, debug, nil, nil); err != nil { 157 | colorError.Printf("❌ Failed to download album %s: %v\n", album.Title, err) 158 | } else { 159 | colorSuccess.Println("✅ Album download completed for", album.Title) 160 | } 161 | case "track": 162 | track := selectedItem.(Track) 163 | colorInfo.Println("🎵 Starting track download for:", track.Title, "by", track.Artist) 164 | // Now call the modified DownloadSingleTrack which expects a Track object and potentially a pool 165 | if err := api.DownloadSingleTrack(context.Background(), track, debug, config.Format, config.Bitrate, pool, config, nil); err != nil { 166 | colorError.Printf("❌ Failed to download track %s: %v\n", track.Title, err) 167 | } else { 168 | colorSuccess.Println("✅ Track download completed for", track.Title) 169 | } 170 | default: 171 | colorError.Println("❌ Unknown item type selected.") 172 | } 173 | } 174 | 175 | if localPool && pool != nil { 176 | pool.Stop() 177 | } 178 | }, 179 | } 180 | 181 | var spotifyCmd = &cobra.Command{ 182 | Use: "spotify [url]", 183 | Short: "Download a Spotify playlist or album.", 184 | Args: cobra.ExactArgs(1), 185 | Run: func(cmd *cobra.Command, args []string) { 186 | config, api := initConfigAndAPI() 187 | if config.Format != "flac" && !CheckFFmpeg() { 188 | colorError.Println("❌ ffmpeg is not installed or not in your PATH. Please install ffmpeg to use the format conversion feature.") 189 | return 190 | } 191 | url := args[0] 192 | 193 | spotifyClient := NewSpotifyClient(config.SpotifyClientID, config.SpotifyClientSecret) 194 | if err := spotifyClient.Authenticate(); err != nil { 195 | colorError.Printf("❌ Failed to authenticate with Spotify: %v\n", err) 196 | return 197 | } 198 | 199 | var spotifyTracks []SpotifyTrack 200 | var err error 201 | 202 | if strings.Contains(url, "/playlist/") { 203 | spotifyTracks, _, err = spotifyClient.GetPlaylistTracks(url) 204 | } else if strings.Contains(url, "/album/") { 205 | spotifyTracks, _, err = spotifyClient.GetAlbumTracks(url) // I need to implement this 206 | } else { 207 | colorError.Println("❌ Invalid Spotify URL. Please provide a playlist or album URL.") 208 | return 209 | } 210 | 211 | if err != nil { 212 | colorError.Printf("❌ Failed to get tracks from Spotify: %v\n", err) 213 | return 214 | } 215 | 216 | if expandPlaylist { 217 | colorInfo.Println("Expanding playlist to download full albums...") 218 | 219 | // --- Logic for --expand flag --- 220 | 221 | uniqueAlbums := make(map[string]SpotifyTrack) 222 | for _, track := range spotifyTracks { 223 | // Use a consistent key for the map 224 | albumKey := strings.ToLower(track.AlbumName + " - " + track.AlbumArtist) 225 | if _, exists := uniqueAlbums[albumKey]; !exists { 226 | 227 | uniqueAlbums[albumKey] = track 228 | } 229 | } 230 | 231 | colorInfo.Printf("Found %d unique albums in the playlist.\n", len(uniqueAlbums)) 232 | 233 | for _, track := range uniqueAlbums { 234 | albumSearchQuery := track.AlbumName + " - " + track.AlbumArtist 235 | colorInfo.Printf("Searching for album: %s\n", albumSearchQuery) 236 | 237 | // Use handleSearch to find the album on DAB 238 | selectedItems, itemTypes, err := handleSearch(context.Background(), api, albumSearchQuery, "album", debug, auto) 239 | if err != nil { 240 | colorError.Printf("❌ Search failed for album '%s': %v\n", albumSearchQuery, err) 241 | continue // Move to the next album 242 | } 243 | 244 | if len(selectedItems) == 0 { 245 | colorWarning.Printf("⚠️ No results found for album: %s\n", albumSearchQuery) 246 | continue 247 | } 248 | 249 | // Download the first result (or the one selected by the user) 250 | for i, selectedItem := range selectedItems { 251 | if itemTypes[i] == "album" { 252 | album := selectedItem.(Album) 253 | colorInfo.Println("🎵 Starting album download for:", album.Title, "by", album.Artist) 254 | if _, err := api.DownloadAlbum(context.Background(), album.ID, config, debug, nil, nil); err != nil { 255 | colorError.Printf("❌ Failed to download album %s: %v\n", album.Title, err) 256 | } else { 257 | colorSuccess.Println("✅ Album download completed for", album.Title) 258 | } 259 | break // Only download the first album result for this search 260 | } 261 | } 262 | } 263 | // --- End of logic for --expand flag --- 264 | return // Exit after album downloads are done 265 | } 266 | 267 | var tracks []string 268 | for _, spotifyTrack := range spotifyTracks { 269 | tracks = append(tracks, spotifyTrack.Name+" - "+spotifyTrack.Artist) 270 | } 271 | 272 | // Initialize pool for multiple track downloads 273 | var pool *pb.Pool 274 | var localPool bool 275 | if isTTY() && len(spotifyTracks) > 1 { // Only create pool if multiple items and TTY 276 | var err error 277 | pool, err = pb.StartPool() 278 | if err != nil { 279 | colorError.Printf("❌ Failed to start progress bar pool: %v\n", err) 280 | // Continue without the pool 281 | } else { 282 | localPool = true 283 | } 284 | } 285 | 286 | for _, spotifyTrack := range spotifyTracks { 287 | trackName := spotifyTrack.Name + " - " + spotifyTrack.Artist // Construct search query 288 | selectedItems, itemTypes, err := handleSearch(context.Background(), api, trackName, "track", debug, auto) 289 | if err != nil { 290 | colorError.Printf("❌ Search failed for track %s: %v\n", trackName, err) 291 | if pool != nil { 292 | pool.Stop() // Stop pool on error 293 | } 294 | return // Exit on search error 295 | } 296 | 297 | if len(selectedItems) == 0 { 298 | colorWarning.Printf("⚠️ No results found for track: %s\n", trackName) 299 | continue 300 | } 301 | 302 | for i, selectedItem := range selectedItems { 303 | itemType := itemTypes[i] 304 | if itemType == "track" { 305 | track := selectedItem.(Track) 306 | colorInfo.Println("🎵 Starting track download for:", track.Title, "by", track.Artist) 307 | if err := api.DownloadSingleTrack(context.Background(), track, debug, config.Format, config.Bitrate, pool, config, nil); err != nil { 308 | colorError.Printf("❌ Failed to download track %s: %v\n", track.Title, err) 309 | } else { 310 | colorSuccess.Println("✅ Track download completed for", track.Title) 311 | } 312 | } 313 | } 314 | } 315 | 316 | if localPool && pool != nil { 317 | pool.Stop() 318 | } 319 | }, 320 | } 321 | 322 | var navidromeCmd = &cobra.Command{ 323 | Use: "navidrome [spotify_url]", 324 | Short: "Copy a Spotify playlist or album to Navidrome.", 325 | Args: cobra.ExactArgs(1), 326 | Run: func(cmd *cobra.Command, args []string) { 327 | config, api := initConfigAndAPI() 328 | spotifyURL := args[0] 329 | 330 | spotifyClient := NewSpotifyClient(config.SpotifyClientID, config.SpotifyClientSecret) 331 | if err := spotifyClient.Authenticate(); err != nil { 332 | colorError.Printf("❌ Failed to authenticate with Spotify: %v\n", err) 333 | return 334 | } 335 | 336 | var spotifyTracks []SpotifyTrack 337 | var spotifyName string 338 | var err error 339 | 340 | if strings.Contains(spotifyURL, "/playlist/") { 341 | spotifyTracks, spotifyName, err = spotifyClient.GetPlaylistTracks(spotifyURL) 342 | } else if strings.Contains(spotifyURL, "/album/") { 343 | spotifyTracks, spotifyName, err = spotifyClient.GetAlbumTracks(spotifyURL) 344 | } else { 345 | colorError.Println("❌ Invalid Spotify URL. Please provide a playlist or album URL.") 346 | return 347 | } 348 | 349 | if err != nil { 350 | colorError.Printf("❌ Failed to get tracks from Spotify: %v\n", err) 351 | return 352 | } 353 | 354 | navidromeClient := NewNavidromeClient(config.NavidromeURL, config.NavidromeUsername, config.NavidromePassword) 355 | if err := navidromeClient.Authenticate(); err != nil { 356 | colorError.Printf("❌ Failed to authenticate with Navidrome: %v\n", err) 357 | return 358 | } 359 | 360 | if expandNavidrome { 361 | colorInfo.Println("Expanding playlist to download full albums...") 362 | 363 | // --- Logic for --expand flag --- 364 | uniqueAlbums := make(map[string]SpotifyTrack) 365 | for _, track := range spotifyTracks { 366 | // Use a consistent key for the map 367 | albumKey := strings.ToLower(track.AlbumName + " - " + track.AlbumArtist) 368 | if _, exists := uniqueAlbums[albumKey]; !exists { 369 | uniqueAlbums[albumKey] = track 370 | } 371 | } 372 | 373 | colorInfo.Printf("Found %d unique albums in the playlist.\n", len(uniqueAlbums)) 374 | 375 | for _, track := range uniqueAlbums { 376 | albumSearchQuery := track.AlbumName + " - " + track.AlbumArtist 377 | 378 | colorInfo.Printf("Searching for album '%s' by '%s' in Navidrome...", track.AlbumName, track.AlbumArtist) 379 | navidromeAlbum, err := navidromeClient.SearchAlbum(track.AlbumName, track.AlbumArtist) 380 | if err != nil { 381 | colorWarning.Printf("⚠️ Error searching for album %s in Navidrome: %v\n", albumSearchQuery, err) 382 | } else if navidromeAlbum != nil { 383 | colorSuccess.Printf("✅ Album '%s' already exists in Navidrome, skipping download.\n", albumSearchQuery) 384 | continue // Skip to the next album 385 | } 386 | colorInfo.Printf("Searching for album: %s\n", albumSearchQuery) 387 | 388 | // Use handleSearch to find the album on DAB 389 | selectedItems, itemTypes, err := handleSearch(context.Background(), api, albumSearchQuery, "album", debug, auto) 390 | if err != nil { 391 | colorError.Printf("❌ Search failed for album '%s': %v\n", albumSearchQuery, err) 392 | continue // Move to the next album 393 | } 394 | 395 | if len(selectedItems) == 0 { 396 | colorWarning.Printf("⚠️ No results found for album: %s\n", albumSearchQuery) 397 | continue 398 | } 399 | 400 | // Download the first result (or the one selected by the user) 401 | for i, selectedItem := range selectedItems { 402 | if itemTypes[i] == "album" { 403 | album := selectedItem.(Album) 404 | colorInfo.Println("🎵 Starting album download for:", album.Title, "by", album.Artist) 405 | if _, err := api.DownloadAlbum(context.Background(), album.ID, config, debug, nil, nil); err != nil { 406 | colorError.Printf("❌ Failed to download album %s: %v\n", album.Title, err) 407 | } else { 408 | colorSuccess.Println("✅ Album download completed for", album.Title) 409 | } 410 | break // Only download the first album result for this search 411 | } 412 | } 413 | } 414 | // --- End of logic for --expand flag --- 415 | } 416 | playlistName := GetUserInput("Enter a name for the new Navidrome playlist", spotifyName) // MODIFIED 417 | if err := navidromeClient.CreatePlaylist(playlistName); err != nil { 418 | colorError.Printf("❌ Failed to create Navidrome playlist: %v\n", err) 419 | return 420 | } 421 | 422 | playlistID, err := navidromeClient.SearchPlaylist(playlistName) 423 | if err != nil { 424 | colorError.Printf("❌ Failed to find newly created playlist '%s': %v\n", playlistName, err) 425 | return 426 | } 427 | 428 | var navidromeTrackIDs []string // New slice to store Navidrome track IDs 429 | 430 | for _, spotifyTrack := range spotifyTracks { // Iterate over SpotifyTrack 431 | trackName := spotifyTrack.Name 432 | if ignoreSuffix != "" { 433 | trackName = removeSuffix(trackName, ignoreSuffix) 434 | } 435 | track, err := navidromeClient.SearchTrack(trackName, spotifyTrack.Artist, spotifyTrack.AlbumName) 436 | if err != nil { 437 | colorWarning.Printf("⚠️ Error searching for track %s by %s on Navidrome: %v\n", spotifyTrack.Name, spotifyTrack.Artist, err) 438 | continue 439 | } 440 | 441 | if track != nil { 442 | navidromeTrackIDs = append(navidromeTrackIDs, track.ID) // Collect track IDs 443 | colorSuccess.Println("track is already found skipping") 444 | } else { 445 | colorWarning.Printf("⚠️ Track %s by %s not found on Navidrome. Searching DAB...\n", spotifyTrack.Name, spotifyTrack.Artist) 446 | 447 | // Search DAB for the track 448 | dabSearchQuery := spotifyTrack.Name + " - " + spotifyTrack.Artist 449 | if ignoreSuffix != "" { 450 | dabSearchQuery = trackName + " - " + spotifyTrack.Artist 451 | } 452 | dabSearchResults, dabItemTypes, err := handleSearch(context.Background(), api, dabSearchQuery, "track", debug, auto) 453 | if err != nil { 454 | colorError.Printf("❌ Failed to search DAB for %s: %v\n", spotifyTrack.Name, err) 455 | continue 456 | } 457 | 458 | if len(dabSearchResults) > 0 { 459 | // Assuming the first result is the desired one if auto is true, or user selected one 460 | selectedDabItem := dabSearchResults[0] 461 | selectedDabItemType := dabItemTypes[0] 462 | 463 | if selectedDabItemType == "track" { 464 | dabTrack := selectedDabItem.(Track) 465 | colorInfo.Printf("🎵 Downloading %s by %s from DAB...\n", dabTrack.Title, dabTrack.Artist) 466 | if err := api.DownloadSingleTrack(context.Background(), dabTrack, debug, config.Format, config.Bitrate, nil, config, nil); err != nil { 467 | colorError.Printf("❌ Failed to download track %s from DAB: %v\n", dabTrack.Title, err) 468 | } else { 469 | colorSuccess.Printf("✅ Downloaded %s by %s from DAB. It should appear in Navidrome soon.\n", dabTrack.Title, dabTrack.Artist) 470 | // After downloading, try to search for it in Navidrome again and add to playlist 471 | // This might require a small delay for Navidrome to scan the new file 472 | time.Sleep(5 * time.Second) // Give Navidrome some time to scan 473 | reScannedTrack, err := navidromeClient.SearchTrack(dabTrack.Title, dabTrack.Artist, dabTrack.Album) 474 | if err != nil { 475 | colorWarning.Printf("⚠️ Failed to re-search for downloaded track %s in Navidrome: %v\n", dabTrack.Title, err) 476 | } else if reScannedTrack != nil { 477 | navidromeTrackIDs = append(navidromeTrackIDs, reScannedTrack.ID) 478 | colorSuccess.Printf("✅ Found newly downloaded track %s in Navidrome (ID: %s) and added to list for playlist.\n", reScannedTrack.Title, reScannedTrack.ID) 479 | } else { 480 | colorWarning.Printf("⚠️ Downloaded track %s not found in Navidrome after re-scan. It might be added later manually.\n", dabTrack.Title) 481 | } 482 | } 483 | } else { 484 | colorWarning.Printf("⚠️ DAB search for %s returned a non-track item type: %s. Skipping download.\n", spotifyTrack.Name, selectedDabItemType) 485 | } 486 | } else { 487 | colorWarning.Printf("⚠️ No results found on DAB for %s.\n", spotifyTrack.Name) 488 | } 489 | } 490 | } 491 | 492 | // Add all collected tracks to the playlist in a single call 493 | if len(navidromeTrackIDs) > 0 { 494 | if err := navidromeClient.AddTracksToPlaylist(playlistID, navidromeTrackIDs); err != nil { // New method call 495 | colorError.Printf("❌ Failed to add tracks to Navidrome playlist: %v\n", err) 496 | } else { 497 | colorSuccess.Printf("✅ Successfully added %d tracks to Navidrome playlist '%s'\n", len(navidromeTrackIDs), playlistName) 498 | 499 | // Verify that the tracks were added 500 | time.Sleep(2 * time.Second) // Add a small delay 501 | playlistTracks, err := navidromeClient.GetPlaylistTracks(playlistID) 502 | if err != nil { 503 | colorWarning.Printf("⚠️ Failed to verify playlist tracks: %v\n", err) 504 | } else { 505 | colorInfo.Printf("🔍 Found %d tracks in playlist '%s'\n", len(playlistTracks), playlistName) 506 | if len(playlistTracks) == len(navidromeTrackIDs) { 507 | colorSuccess.Println("✅ All tracks successfully added to the playlist.") 508 | } else { 509 | colorWarning.Printf("⚠️ Expected %d tracks, but found %d.\n", len(navidromeTrackIDs), len(playlistTracks)) 510 | } 511 | } 512 | } 513 | } else { 514 | colorWarning.Printf("⚠️ No tracks found to add to Navidrome playlist '%s'\n", playlistName) 515 | } 516 | }, 517 | } 518 | 519 | var addToPlaylistCmd = &cobra.Command{ 520 | Use: "add-to-playlist [playlist_id] [song_id...]", 521 | Short: "Add one or more songs to a Navidrome playlist.", 522 | Args: cobra.MinimumNArgs(2), // At least playlist ID and one song ID 523 | Run: func(cmd *cobra.Command, args []string) { 524 | config, _ := initConfigAndAPI() 525 | 526 | navidromeClient := NewNavidromeClient(config.NavidromeURL, config.NavidromeUsername, config.NavidromePassword) 527 | if err := navidromeClient.Authenticate(); err != nil { 528 | colorError.Printf("❌ Failed to authenticate with Navidrome: %v\n", err) 529 | return 530 | } 531 | 532 | playlistID := args[0] 533 | songIDs := args[1:] 534 | 535 | if err := navidromeClient.AddTracksToPlaylist(playlistID, songIDs); err != nil { 536 | colorError.Printf("❌ Failed to add tracks to playlist %s: %v\n", playlistID, err) 537 | } else { 538 | colorSuccess.Printf("✅ Successfully added %d tracks to playlist %s\n", len(songIDs), playlistID) 539 | } 540 | }, 541 | } 542 | 543 | var debugCmd = &cobra.Command{ 544 | Use: "debug", 545 | Short: "Run various debugging utilities.", 546 | Long: "Provides commands to test API connectivity and artist endpoint formats.", 547 | } 548 | 549 | var testApiAvailabilityCmd = &cobra.Command{ 550 | Use: "api-availability", 551 | Short: "Test basic DAB API connectivity.", 552 | Run: func(cmd *cobra.Command, args []string) { 553 | _, api := initConfigAndAPI() 554 | api.TestAPIAvailability(context.Background()) 555 | }, 556 | } 557 | 558 | var testArtistEndpointsCmd = &cobra.Command{ 559 | Use: "artist-endpoints [artist_id]", 560 | Short: "Test different artist endpoint formats for a given artist ID.", 561 | Args: cobra.ExactArgs(1), 562 | Run: func(cmd *cobra.Command, args []string) { 563 | _, api := initConfigAndAPI() 564 | artistID := args[0] 565 | api.TestArtistEndpoints(context.Background(), artistID) 566 | }, 567 | } 568 | 569 | var comprehensiveArtistDebugCmd = &cobra.Command{ 570 | Use: "comprehensive-artist-debug [artist_id]", 571 | Short: "Perform comprehensive debugging for an artist ID (API connectivity, endpoint formats, and ID type checks).", 572 | Args: cobra.ExactArgs(1), 573 | Run: func(cmd *cobra.Command, args []string) { 574 | _, api := initConfigAndAPI() 575 | artistID := args[0] 576 | api.DebugArtistID(context.Background(), artistID) 577 | }, 578 | } 579 | 580 | var versionCmd = &cobra.Command{ 581 | Use: "version", 582 | Short: "Print the version number of dab-downloader", 583 | Run: func(cmd *cobra.Command, args []string) { 584 | fmt.Printf("dab-downloader %s\n", toolVersion) 585 | }, 586 | } 587 | 588 | func printInstallInstructions() { 589 | 590 | fmt.Println("\n📦 Install FFmpeg:") 591 | fmt.Println("• Windows: choco install ffmpeg or winget install ffmpeg") 592 | fmt.Println("• macOS: brew install ffmpeg") 593 | fmt.Println("• Ubuntu: sudo apt install ffmpeg") 594 | fmt.Println("• Arch: sudo pacman -S ffmpeg") 595 | fmt.Println("\n🔄 Restart the application after installation") 596 | } 597 | 598 | func initConfigAndAPI() (*Config, *DabAPI) { 599 | color.NoColor = !isTTY() // Initialize color output 600 | homeDir, err := os.UserHomeDir() 601 | if err != nil { 602 | colorWarning.Println("⚠️ Could not determine home directory, will use current directory for downloads.") 603 | homeDir = "." // or some other sensible default 604 | } 605 | 606 | config := &Config{ 607 | APIURL: "https://dabmusic.xyz", 608 | DownloadLocation: filepath.Join(homeDir, "Music"), 609 | Parallelism: 5, 610 | UpdateRepo: "PrathxmOp/dab-downloader", // Default value 611 | VerifyDownloads: true, // Enable download verification by default 612 | MaxRetryAttempts: defaultMaxRetries, // Use default retry attempts 613 | WarningBehavior: "summary", // Default to summary mode for cleaner output 614 | } 615 | 616 | // Define the config file path in the current directory 617 | configFile := filepath.Join("config", "config.json") 618 | 619 | // Check if config file exists 620 | if !FileExists(configFile) { 621 | colorInfo.Println("✨ Welcome to DAB Downloader! Let's set up your configuration.") 622 | 623 | // Prompt for API URL 624 | defaultAPIURL := config.APIURL 625 | config.APIURL = GetUserInput(fmt.Sprintf("Enter DAB API URL (e.g., %s)", defaultAPIURL), defaultAPIURL) 626 | 627 | // Prompt for Download Location 628 | defaultDownloadLocation := config.DownloadLocation 629 | config.DownloadLocation = GetUserInput(fmt.Sprintf("Enter download location (e.g., %s)", defaultDownloadLocation), defaultDownloadLocation) 630 | 631 | // Prompt for Parallelism 632 | defaultParallelism := strconv.Itoa(config.Parallelism) 633 | parallelismStr := GetUserInput(fmt.Sprintf("Enter number of parallel downloads (default: %s)", defaultParallelism), defaultParallelism) 634 | if p, err := strconv.Atoi(parallelismStr); err == nil && p > 0 { 635 | config.Parallelism = p 636 | } else { 637 | colorWarning.Printf("⚠️ Invalid parallelism value '%s', using default %d.\n", parallelismStr, config.Parallelism) 638 | } 639 | 640 | // Prompt for Spotify Credentials 641 | config.SpotifyClientID = GetUserInput("Enter your Spotify Client ID", "") 642 | config.SpotifyClientSecret = GetUserInput("Enter your Spotify Client Secret", "") 643 | 644 | // Prompt for Navidrome Credentials 645 | config.NavidromeURL = GetUserInput("Enter your Navidrome URL", "") 646 | config.NavidromeUsername = GetUserInput("Enter your Navidrome Username", "") 647 | config.NavidromePassword = GetUserInput("Enter your Navidrome Password", "") 648 | 649 | // Prompt for Format and Bitrate 650 | config.Format = GetUserInput("Enter default output format (e.g., flac, mp3, ogg, opus)", "flac") 651 | config.Bitrate = GetUserInput("Enter default bitrate for lossy formats (e.g., 320)", "320") 652 | 653 | // Prompt for Update Repository 654 | config.UpdateRepo = GetUserInput("Enter GitHub repository for updates (e.g., PrathxmOp/dab-downloader)", "PrathxmOp/dab-downloader") 655 | 656 | // Save the new config 657 | if err := SaveConfig(configFile, config); err != nil { 658 | colorError.Printf("❌ Failed to save initial config: %v\n", err) 659 | } else { 660 | colorSuccess.Println("✅ Configuration saved to", configFile) 661 | } 662 | } else { 663 | // Load existing config 664 | if err := LoadConfig(configFile, config); err != nil { 665 | colorError.Printf("❌ Failed to load config from %s: %v\n", configFile, err) 666 | } else { 667 | colorInfo.Println("✅ Loaded configuration from", configFile) 668 | // Set defaults if not present in config file 669 | if config.Format == "" { 670 | config.Format = "flac" 671 | } 672 | if config.Bitrate == "" { 673 | config.Bitrate = "320" 674 | } 675 | 676 | } 677 | } 678 | 679 | // Command-line flags override config file 680 | if apiURL != "" { 681 | config.APIURL = apiURL 682 | } 683 | if downloadLocation != "" { 684 | config.DownloadLocation = downloadLocation 685 | } 686 | if spotifyClientID != "" { 687 | config.SpotifyClientID = spotifyClientID 688 | } 689 | if spotifyClientSecret != "" { 690 | config.SpotifyClientSecret = spotifyClientSecret 691 | } 692 | if navidromeURL != "" { 693 | config.NavidromeURL = navidromeURL 694 | } 695 | if navidromeUsername != "" { 696 | config.NavidromeUsername = navidromeUsername 697 | } 698 | if navidromePassword != "" { 699 | config.NavidromePassword = navidromePassword 700 | } 701 | 702 | // Override config with command-line flags if provided 703 | if format != "flac" { // Check if format flag was explicitly set 704 | config.Format = format 705 | } 706 | if bitrate != "320" { // Check if bitrate flag was explicitly set 707 | config.Bitrate = bitrate 708 | } 709 | if warningBehavior != "summary" { // Check if warning behavior flag was explicitly set 710 | config.WarningBehavior = warningBehavior 711 | } 712 | 713 | // Validate warning behavior 714 | if config.WarningBehavior != "immediate" && config.WarningBehavior != "summary" && config.WarningBehavior != "silent" { 715 | colorWarning.Printf("⚠️ Invalid warning behavior '%s', using default 'summary'\n", config.WarningBehavior) 716 | config.WarningBehavior = "summary" 717 | } 718 | 719 | // Create a new http.Client 720 | client := &http.Client{ 721 | Timeout: requestTimeout, 722 | } 723 | 724 | if insecure { 725 | client.Transport = &http.Transport{ 726 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 727 | } 728 | } 729 | 730 | api := NewDabAPI(config.APIURL, config.DownloadLocation, client) 731 | return config, api 732 | } 733 | 734 | func init() { 735 | rootCmd.PersistentFlags().StringVar(&apiURL, "api-url", "", "DAB API URL") 736 | rootCmd.PersistentFlags().StringVar(&downloadLocation, "download-location", "", "Directory to save downloads") 737 | rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "Enable debug logging") 738 | rootCmd.PersistentFlags().BoolVar(&insecure, "insecure", false, "Skip TLS certificate verification") 739 | rootCmd.PersistentFlags().StringVar(&warningBehavior, "warnings", "summary", "Warning behavior: 'immediate', 'summary', or 'silent'") 740 | 741 | albumCmd.Flags().StringVar(&format, "format", "flac", "Format to convert to after downloading (e.g., mp3, ogg, opus)") 742 | albumCmd.Flags().StringVar(&bitrate, "bitrate", "320", "Bitrate for lossy formats (in kbps, e.g., 192, 256, 320)") 743 | 744 | artistCmd.Flags().StringVar(&filter, "filter", "all", "Filter by item type (albums, eps, singles), comma-separated") 745 | artistCmd.Flags().BoolVar(&noConfirm, "no-confirm", false, "Skip confirmation prompt") 746 | artistCmd.Flags().StringVar(&format, "format", "flac", "Format to convert to after downloading (e.g., mp3, ogg, opus)") 747 | artistCmd.Flags().StringVar(&bitrate, "bitrate", "320", "Bitrate for lossy formats (in kbps, e.g., 192, 256, 320)") 748 | 749 | searchCmd.Flags().StringVar(&searchType, "type", "all", "Type of content to search for (artist, album, track, all)") 750 | searchCmd.Flags().BoolVar(&auto, "auto", false, "Automatically download the first result") 751 | searchCmd.Flags().StringVar(&format, "format", "flac", "Format to convert to after downloading (e.g., mp3, ogg, opus)") 752 | searchCmd.Flags().StringVar(&bitrate, "bitrate", "320", "Bitrate for lossy formats (in kbps, e.g., 192, 256, 320)") 753 | 754 | spotifyCmd.Flags().StringVar(&spotifyPlaylist, "spotify", "", "Spotify playlist URL to download") 755 | spotifyCmd.Flags().BoolVar(&auto, "auto", false, "Automatically download the first result") 756 | spotifyCmd.Flags().BoolVar(&expandPlaylist, "expand", false, "Expand playlist tracks to download the full albums") 757 | spotifyCmd.Flags().StringVar(&format, "format", "flac", "Format to convert to after downloading (e.g., mp3, ogg, opus)") 758 | spotifyCmd.Flags().StringVar(&bitrate, "bitrate", "320", "Bitrate for lossy formats (in kbps, e.g., 192, 256, 320)") 759 | rootCmd.PersistentFlags().StringVar(&spotifyClientID, "spotify-client-id", "", "Spotify Client ID") 760 | rootCmd.PersistentFlags().StringVar(&spotifyClientSecret, "spotify-client-secret", "", "Spotify Client Secret") 761 | 762 | rootCmd.PersistentFlags().StringVar(&navidromeURL, "navidrome-url", "", "Navidrome URL") 763 | rootCmd.PersistentFlags().StringVar(&navidromeUsername, "navidrome-username", "", "Navidrome Username") 764 | rootCmd.PersistentFlags().StringVar(&navidromePassword, "navidrome-password", "", "Navidrome Password") 765 | navidromeCmd.Flags().StringVar(&ignoreSuffix, "ignore-suffix", "", "Ignore suffix when searching for tracks") 766 | navidromeCmd.Flags().BoolVar(&expandNavidrome, "expand", false, "Expand playlist tracks to download the full albums") 767 | navidromeCmd.Flags().BoolVar(&auto, "auto", false, "Automatically download the first result") 768 | 769 | rootCmd.AddCommand(artistCmd) 770 | rootCmd.AddCommand(albumCmd) 771 | rootCmd.AddCommand(searchCmd) 772 | rootCmd.AddCommand(spotifyCmd) 773 | rootCmd.AddCommand(navidromeCmd) 774 | rootCmd.AddCommand(addToPlaylistCmd) 775 | rootCmd.AddCommand(debugCmd) 776 | 777 | debugCmd.AddCommand(testApiAvailabilityCmd) 778 | debugCmd.AddCommand(testArtistEndpointsCmd) 779 | debugCmd.AddCommand(comprehensiveArtistDebugCmd) 780 | 781 | rootCmd.AddCommand(versionCmd) 782 | } 783 | 784 | func main() { 785 | var versionInfo VersionInfo 786 | if err := json.Unmarshal(versionJSON, &versionInfo); err != nil { 787 | fmt.Fprintf(os.Stderr, "Error reading embedded version.json: %v\n", err) 788 | os.Exit(1) 789 | } 790 | toolVersion = versionInfo.Version 791 | 792 | // Set rootCmd.Version after toolVersion is populated 793 | rootCmd.Version = toolVersion 794 | 795 | // Set rootCmd.Long after toolVersion is populated 796 | rootCmd.Long = fmt.Sprintf("DAB Downloader (v%s) by %s\n\nA modular, high-quality FLAC music downloader with comprehensive metadata support for the DAB API.\nIt allows you to:\n- Download entire artist discographies.\n- Download full albums.\n- Download individual tracks (by fetching their respective album first).\n- Import and download Spotify playlists and albums.\n- Convert downloaded files to various formats (e.g., MP3, OGG, Opus) with specified bitrates.\n\nAll downloads feature smart categorization, duplicate detection, and embedded cover art.", toolVersion, authorName) 797 | 798 | // Now call CheckForUpdates with the config 799 | config, _ := initConfigAndAPI() // Temporarily load config here to get IsDockerContainer 800 | 801 | // Check if running in Docker 802 | if _, err := os.Stat("/.dockerenv"); err == nil { 803 | config.IsDockerContainer = true 804 | } 805 | 806 | CheckForUpdates(config, toolVersion) 807 | if err := rootCmd.Execute(); err != nil { 808 | fmt.Println(err) 809 | os.Exit(1) 810 | } 811 | } 812 | --------------------------------------------------------------------------------