diff --git a/.DS_Store b/.DS_Store index f4f9633..5092db0 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e0882e6 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Spotify API credentials +# Create an application at https://developer.spotify.com/dashboard +# Set the redirect URI to http://localhost:8888/callback in your application settings +SPOTIFY_CLIENT_ID= +SPOTIFY_CLIENT_SECRET= + +# Deezer authentication +# Find your ARL token in the cookies after logging into Deezer in your browser +DEEZER_ARL= \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d513eb6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,54 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.23" + + - name: Build binaries + run: | + # Get version from tag + VERSION=${GITHUB_REF#refs/tags/} + LDFLAGS="-s -w -X github.com/d-fi/GoFi/cmd/gofi/cmd.version=$VERSION" + + # Build for multiple platforms + GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o gofi-darwin-amd64 ./cmd/gofi + GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o gofi-darwin-arm64 ./cmd/gofi + GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o gofi-linux-amd64 ./cmd/gofi + GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o gofi-linux-arm64 ./cmd/gofi + GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o gofi-windows-amd64.exe ./cmd/gofi + + # Create archives + tar -czf gofi-darwin-amd64.tar.gz gofi-darwin-amd64 + tar -czf gofi-darwin-arm64.tar.gz gofi-darwin-arm64 + tar -czf gofi-linux-amd64.tar.gz gofi-linux-amd64 + tar -czf gofi-linux-arm64.tar.gz gofi-linux-arm64 + zip gofi-windows-amd64.zip gofi-windows-amd64.exe + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: | + gofi-darwin-amd64.tar.gz + gofi-darwin-arm64.tar.gz + gofi-linux-amd64.tar.gz + gofi-linux-arm64.tar.gz + gofi-windows-amd64.zip + draft: false + prerelease: false + generate_release_notes: true \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3f61144..9d1926b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,6 +23,4 @@ jobs: run: go mod tidy - name: Run tests - env: - DEEZER_ARL: ${{ secrets.DEEZER_ARL }} run: go test -v ./... diff --git a/.gitignore b/.gitignore index 3b735ec..31e169e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ *.dll *.so *.dylib +d-fi +gofi # Test binary, built with `go test -c` *.test @@ -17,5 +19,53 @@ # Dependency directories (remove the comment below to include it) # vendor/ +# Config files that may contain credentials +d-fi.config.json +*.env + +# Build and output directories +bin/ +downloads/ +build/ +.DS_Store + # Go workspace file go.work +d-fi-core.txt +**/.claude/settings.local.json + +.env + +# Added by Claude Task Master +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +dev-debug.log +# Dependency directories +node_modules/ +# Environment variables +# Editor directories and files +.idea +.vscode +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +# OS specific +# Task files +tasks.json +tasks/ + +# Editor/IDE specific files +.cursor/ +.taskmasterconfig +.roo/ +.roomodes +.windsurfrules + +# Example/template files +scripts/example_prd.txt \ No newline at end of file diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..c9953ae --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +golang 1.23.1 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0ef1b84 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,263 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +GoFi is a Go implementation of a music download tool, originally for Deezer but expanding to support multiple services including Spotify. It allows users to search and download music from these platforms through a command-line interface. + +## Build Commands + +```bash +# Build the new CLI with Spotify support +make build-cli + +# Build and install locally +make install # Install to /usr/local/bin +make install PREFIX=$HOME/.local # Install to custom directory +make install-dev # Create symlink for development + +# Build the legacy CLI (Deezer only) +make build + +# Build all binaries +make build-all + +# Run tests +make test + +# Clean build artifacts +make clean + +# Uninstall +make uninstall + +# Build with version injection +go build -ldflags "-X github.com/d-fi/GoFi/cmd/gofi/cmd.version=$(git describe --tags --always)" -o gofi ./cmd/gofi +``` + +## Installation Methods + +### For End Users + +```bash +# One-liner install (macOS/Linux) +curl -fsSL https://raw.githubusercontent.com/d-fi/GoFi/main/scripts/install.sh | bash + +# One-liner install (Windows PowerShell) +iwr -useb https://raw.githubusercontent.com/d-fi/GoFi/main/scripts/install.ps1 | iex +``` + +### Installation Scripts + +- `scripts/install.sh`: Cross-platform bash script that detects OS/arch and downloads appropriate binary +- `scripts/install.ps1`: PowerShell script for Windows installation with PATH management + +## Usage Examples + +```bash +# Authenticate with Deezer (automatic browser cookie detection) +gofi auth deezer + +# Authenticate with Spotify (required before using Spotify features) +gofi auth spotify + +# Download a Spotify track and convert to FLAC +gofi -q 9 download https://open.spotify.com/track/2YarjDYjBJuH63dUIh9OWv + +# Download a Spotify album to a specific directory +gofi -o ~/Music/Downloads -q 3 download https://open.spotify.com/album/1DFixLWuPkv3KT3TnV35m3 + +# Download a Spotify playlist in FLAC quality to a specific directory +gofi -o ~/Music/Playlists -q 9 download https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M + +# Download from Deezer URL (no Spotify auth needed) +gofi -q 9 download https://www.deezer.com/track/3135556 + +# Check version +gofi --version +``` + +## Code Architecture + +### Core Components + +1. **API Clients**: Service-specific API clients for fetching music data + - Deezer: `api/api.go` + - Spotify: `internal/services/spotify/spotify.go` + - Service Matching: `api/spotify_deezer_match.go` matches content between services + +2. **Authentication**: + - Deezer: + - Automatic browser cookie detection: `internal/auth/browser_cookies.go` + - Supports Chrome, Firefox, Safari, Edge, and Arc browsers + - ARL helper functions: `internal/auth/arl_helper.go` + - CLI command: `cmd/gofi/cmd/auth_deezer.go` + - No longer requires manual DEEZER_ARL environment variable + - Spotify: + - OAuth2 flow in `internal/services/spotify/auth.go` + - Stores tokens in `~/.config/gofi/spotify_token.json` + - CLI command: `cmd/gofi/cmd/auth_spotify.go` + +3. **Download Engine**: `download/download.go` handles the actual download of music files + - Concurrent downloads for albums/playlists + - Quality selection (FLAC, MP3 320kbps, MP3 128kbps) + - File existence checking to avoid re-downloads + - Improved error handling with retry logic + +4. **Metadata Management**: Adds appropriate metadata to downloaded files + - ID3 tags for MP3: `metadata/id3_tag.go` + - FLAC metadata: `metadata/flac_meta.go` + - Album cover art: `metadata/album_cover.go` + +5. **CLI Interface**: + - Legacy flag-based CLI: `cmd/main.go` (Deezer only) + - New Cobra-based CLI: `cmd/gofi/cmd/` (includes Spotify support) + - Improved download handler: `cmd/gofi/cmd/download_handler_improved.go` + - Version support with automatic injection during build + +6. **User Interface**: Beautiful terminal output + - Display manager: `internal/ui/display.go` + - Custom progress bars: `internal/ui/simple_progress.go` + - Color-coded output using `github.com/fatih/color` + - Icons and visual feedback for better UX + +### Data Flow + +#### For Spotify URLs: +1. User provides a Spotify URL +2. URL parsing (`internal/utils/urlparser.go`) identifies the content type (track, album, playlist) +3. Spotify API fetches metadata through OAuth2 authentication +4. The Spotify content is matched to equivalent Deezer content (`api/spotify_deezer_match.go`) +5. Deezer API is used to download the matched tracks +6. Metadata is added to downloaded files +7. Files are saved according to the format: ` - .` + +#### For Deezer URLs: +1. User provides a Deezer URL +2. URL parsing (`internal/utils/urlparser.go`) identifies the content type (track, album, playlist) +3. Deezer API fetches content directly (no Spotify auth needed) +4. Content is downloaded using the Deezer API +5. Metadata is added to downloaded files +6. Files are saved according to the format: ` - .` + +## Configuration + +The application accepts configuration through: +1. Command-line flags +2. Environment variables in a `.env` file +3. JSON configuration file + +Main environment variables: + +Authentication: +- `SPOTIFY_CLIENT_ID`: Spotify API client ID +- `SPOTIFY_CLIENT_SECRET`: Spotify API client secret +- `DEEZER_ARL`: Deezer authentication token (optional - can be auto-detected) + +Configuration (Priority: CLI flags > Environment variables > Default values): +- `GOFI_OUTPUT_DIR`: Default download directory (default: "./downloads") +- `GOFI_QUALITY`: Default audio quality - 1, 3, or 9 (default: 3) +- `GOFI_LOG_LEVEL`: Default log level - debug, info, warn, error (default: "info") + +## Important File Paths + +- **Spotify Authentication**: `~/.config/gofi/spotify_token.json` - Stores OAuth2 tokens +- **Environment Variables**: `.env` file in project root directory +- **Binary Location**: `gofi` in project root (excluded from git) + +## Cover Size Settings + +When downloading music, ensure appropriate cover sizes are used based on quality: +- MP3 128kbps (quality=1): 500px +- MP3 320kbps (quality=3): 500px +- FLAC (quality=9): 1000px + +## Testing + +Tests no longer require DEEZER_ARL environment variable. They will: +1. Try to get ARL from environment variable +2. Try to get ARL from browser cookies +3. Skip tests if no valid ARL is available + +Run all tests: +```bash +make test +``` + +Run specific tests: +```bash +go test -v ./path/to/package -run TestName +``` + +Current test coverage focuses on: +- URL handling and parsing +- API client functionality +- Metadata management +- Browser cookie extraction +- ARL token validation + +## CI/CD + +### GitHub Actions Workflows + +1. **test.yml**: Runs on all pushes and PRs to main + - Sets up Go 1.23 + - Runs all tests (no longer needs DEEZER_ARL secret) + +2. **release.yml**: Triggered by version tags (e.g., v1.0.0) + - Builds binaries for multiple platforms: + - macOS (Intel and Apple Silicon) + - Linux (AMD64 and ARM64) + - Windows (AMD64) + - Injects version number into binaries + - Creates GitHub release with all binaries + +## Recent Improvements + +1. **Automatic ARL Detection**: + - No manual token configuration needed + - Supports multiple browsers + - Secure cookie extraction with proper decryption + +2. **Installation Scripts**: + - Platform detection and automatic binary selection + - Proper permission handling + - PATH management on Windows + +3. **Build System**: + - Version injection during build + - Multiple installation targets + - Development-friendly symlink option + +4. **Testing**: + - Tests work without environment variables + - Better error handling for invalid tokens + - Graceful test skipping + +5. **Error Handling**: + - Improved error messages throughout + - Better handling of API responses + - Retry logic for network failures + +## Development Tips + +1. Use `make install-dev` during development to create a symlink that updates automatically +2. The `gofi` binary is git-ignored, so it won't be committed +3. Version is automatically set from git tags during build +4. Tests will skip if no valid ARL is available (from env or cookies) +5. Always clean ARL tokens to remove control characters before use + +## Current Status + +The project now includes: +- Full Spotify integration alongside Deezer support +- Automatic URL detection for both Spotify and Deezer URLs +- Beautiful CLI interface with progress bars and colored output +- Smart file management that skips already downloaded files +- Improved error handling and user feedback +- Automatic browser-based authentication for Deezer +- Cross-platform installation scripts +- GitHub Actions for automated testing and releases + +Users can authenticate with Spotify, browse Spotify content, and download matched content from Deezer in high quality formats. Deezer URLs work directly without requiring Spotify authentication. \ No newline at end of file diff --git a/Makefile b/Makefile index daedea0..abe160a 100644 --- a/Makefile +++ b/Makefile @@ -5,17 +5,77 @@ GOCLEAN = $(GOCMD) clean GOTEST = $(GOCMD) test GOGET = $(GOCMD) get -# Name of binary +# Binary names BINARY_NAME = d-fi +NEW_BINARY_NAME = gofi -# Build the binary +# Installation directory +PREFIX ?= /usr/local +BINDIR = $(PREFIX)/bin + +# Detect OS for installation +UNAME_S := $(shell uname -s) +ifeq ($(UNAME_S),Darwin) + INSTALL_CMD = ln -sf +else + INSTALL_CMD = ln -sf +endif + +# Build the legacy binary build: CGO_ENABLED=0 $(GOBUILD) -ldflags "-s -w" -o $(BINARY_NAME) cmd/main.go +# Build the new CLI binary +build-cli: + CGO_ENABLED=0 $(GOBUILD) -ldflags "-s -w -X github.com/d-fi/GoFi/cmd/gofi/cmd.version=$$(git describe --tags --always --dirty 2>/dev/null || echo dev)" -o $(NEW_BINARY_NAME) cmd/gofi/main.go + +# Build all binaries +build-all: build build-cli + +# Install the new CLI binary +install: build-cli + @echo "Installing $(NEW_BINARY_NAME) to $(BINDIR)..." + @mkdir -p $(BINDIR) + @if [ -w $(BINDIR) ]; then \ + cp $(NEW_BINARY_NAME) $(BINDIR)/$(NEW_BINARY_NAME); \ + chmod 755 $(BINDIR)/$(NEW_BINARY_NAME); \ + echo "✓ Installed $(NEW_BINARY_NAME) to $(BINDIR)"; \ + else \ + echo "Installing to $(BINDIR) (requires sudo)..."; \ + sudo cp $(NEW_BINARY_NAME) $(BINDIR)/$(NEW_BINARY_NAME); \ + sudo chmod 755 $(BINDIR)/$(NEW_BINARY_NAME); \ + echo "✓ Installed $(NEW_BINARY_NAME) to $(BINDIR)"; \ + fi + @echo "Run 'gofi --help' to get started" + +# Install using symlink (for development) +install-dev: build-cli + @echo "Creating symlink for $(NEW_BINARY_NAME) in $(BINDIR)..." + @mkdir -p $(BINDIR) + @if [ -w $(BINDIR) ]; then \ + $(INSTALL_CMD) $(PWD)/$(NEW_BINARY_NAME) $(BINDIR)/$(NEW_BINARY_NAME); \ + echo "✓ Symlinked $(NEW_BINARY_NAME) to $(BINDIR)"; \ + else \ + echo "Creating symlink in $(BINDIR) (requires sudo)..."; \ + sudo $(INSTALL_CMD) $(PWD)/$(NEW_BINARY_NAME) $(BINDIR)/$(NEW_BINARY_NAME); \ + echo "✓ Symlinked $(NEW_BINARY_NAME) to $(BINDIR)"; \ + fi + @echo "Run 'gofi --help' to get started" + +# Uninstall +uninstall: + @echo "Removing $(NEW_BINARY_NAME) from $(BINDIR)..." + @if [ -w $(BINDIR)/$(NEW_BINARY_NAME) ]; then \ + rm -f $(BINDIR)/$(NEW_BINARY_NAME); \ + else \ + sudo rm -f $(BINDIR)/$(NEW_BINARY_NAME); \ + fi + @echo "✓ Uninstalled $(NEW_BINARY_NAME)" + # Clean build files clean: $(GOCLEAN) - rm -f $(BINARY_NAME) + rm -f $(BINARY_NAME) $(NEW_BINARY_NAME) # Run tests test: @@ -23,6 +83,6 @@ test: $(GOTEST) -v ./... # Default target -default: build +default: build-cli -.PHONY: build clean test \ No newline at end of file +.PHONY: build build-cli build-all install install-dev uninstall clean test default \ No newline at end of file diff --git a/README.md b/README.md index 3a09880..adf7420 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,295 @@ # GoFi -This project is a fork of the D-Fi application originally written in TypeScript. It is currently under development and aims to enhance and extend the functionality of the original application. +GoFi is a Go implementation of a music download tool. It allows you to download music from both Deezer and Spotify URLs. -### Contributing +## Features + +- **Automatic URL detection** - Works with both Spotify and Deezer URLs +- **Beautiful CLI interface** - Clean progress bars, colored output, and organized display +- Download high-quality music (MP3 128/320kbps, FLAC lossless) +- Support for tracks, albums, and playlists from both services +- **Spotify integration** - Spotify content is matched and downloaded from Deezer +- **Direct Deezer downloads** - No Spotify authentication needed for Deezer URLs +- **Smart file management** - Skips already downloaded files, organized folder structure +- Command-line interface for easy automation +- Written in Go for high performance and cross-platform compatibility +- Support for configuration files and environment variables +- Concurrent downloads for albums and playlists + +## Installation + +### Quick Install (Recommended) + +**One-liner install:** +```bash +# macOS/Linux +curl -fsSL https://raw.githubusercontent.com/d-fi/GoFi/main/scripts/install.sh | bash + +# Windows (PowerShell as Administrator) +iwr -useb https://raw.githubusercontent.com/d-fi/GoFi/main/scripts/install.ps1 | iex +``` + +#### macOS/Linux +```bash +# Download and run the install script +curl -fsSL https://raw.githubusercontent.com/d-fi/GoFi/main/scripts/install.sh | bash + +# Or download the script first to review it +curl -fsSL https://raw.githubusercontent.com/d-fi/GoFi/main/scripts/install.sh -o install.sh +chmod +x install.sh +./install.sh +``` + +#### Windows (PowerShell) +```powershell +# Run the install script +iwr -useb https://raw.githubusercontent.com/d-fi/GoFi/main/scripts/install.ps1 | iex + +# Or download and run manually +Invoke-WebRequest -Uri https://raw.githubusercontent.com/d-fi/GoFi/main/scripts/install.ps1 -OutFile install.ps1 +./install.ps1 +``` + +### Manual Installation + +Download the latest release for your platform from the [releases page](https://github.com/d-fi/GoFi/releases). + +#### macOS +```bash +# Intel Macs +curl -L https://github.com/d-fi/GoFi/releases/latest/download/gofi-darwin-amd64.tar.gz | tar -xz +sudo mv gofi-darwin-amd64 /usr/local/bin/gofi + +# Apple Silicon Macs (M1/M2/M3) +curl -L https://github.com/d-fi/GoFi/releases/latest/download/gofi-darwin-arm64.tar.gz | tar -xz +sudo mv gofi-darwin-arm64 /usr/local/bin/gofi +``` + +#### Linux +```bash +# AMD64 +curl -L https://github.com/d-fi/GoFi/releases/latest/download/gofi-linux-amd64.tar.gz | tar -xz +sudo mv gofi-linux-amd64 /usr/local/bin/gofi + +# ARM64 +curl -L https://github.com/d-fi/GoFi/releases/latest/download/gofi-linux-arm64.tar.gz | tar -xz +sudo mv gofi-linux-arm64 /usr/local/bin/gofi +``` + +#### Windows +Download `gofi-windows-amd64.zip` from the [releases page](https://github.com/d-fi/GoFi/releases) and extract it to a directory in your PATH. + +### Building from Source + +Prerequisites: +- Go 1.23 or later +- Make (optional, for easier building) + +1. Clone the repository: +```bash +git clone https://github.com/d-fi/GoFi.git +cd GoFi +``` + +2. Build and install: +```bash +# Build and install to /usr/local/bin (may require sudo) +make install + +# Or install to a custom directory +make install PREFIX=$HOME/.local + +# For development: create a symlink instead of copying +make install-dev + +# Build only (without installing) +make build-cli +# This creates a 'gofi' binary in the current directory + +# Build using go directly +go build -o gofi ./cmd/gofi +``` + +3. Uninstall (if installed via make): +```bash +make uninstall +``` + +## Usage + +### Setting Up Authentication + +#### Deezer Authentication + +To use GoFi with Deezer, you need a Deezer ARL token. This token is used to authenticate with Deezer's API. + +**Option 1: Automatic Browser Cookie Detection (Recommended)** + +```bash +./gofi auth deezer +``` + +This command will automatically: +- Search for the ARL cookie in your installed browsers (Chrome, Firefox, Edge, Arc, Safari) +- Extract and validate the token +- Save it to your `.env` file + +**Option 2: Manual Token Retrieval** + +1. Log in to [Deezer](https://www.deezer.com) in your web browser +2. Open developer tools (F12 or right-click and select "Inspect") +3. Go to the "Application" tab (in Chrome) or "Storage" tab (in Firefox) +4. Look for cookies (under "Storage" -> "Cookies" -> "https://www.deezer.com") +5. Find the cookie named "arl" and copy its value (should be 192 characters) + +#### Spotify Authentication + +To use GoFi with Spotify, you need to register an application with Spotify: + +1. Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard) +2. Create a new application +3. Set the redirect URI to `http://localhost:8888/callback` +4. Note your Client ID and Client Secret +5. Add these credentials to your `.env` file (see below) + +### Environment Configuration + +#### Authentication Variables + +Create a `.env` file in the project root with the following content: + +``` +# Spotify API credentials +SPOTIFY_CLIENT_ID=your_spotify_client_id_here +SPOTIFY_CLIENT_SECRET=your_spotify_client_secret_here + +# Deezer authentication +DEEZER_ARL=your_deezer_arl_here +``` + +#### GoFi Configuration Variables + +GoFi supports the following environment variables to set default values: + +```bash +# Set default output directory +export GOFI_OUTPUT_DIR="$HOME/Music/Downloads" + +# Set default quality (1=128kbps MP3, 3=320kbps MP3, 9=FLAC) +export GOFI_QUALITY=9 + +# Set default log level (debug, info, warn, error) +export GOFI_LOG_LEVEL=info +``` + +These can also be added to your `.env` file: + +``` +GOFI_OUTPUT_DIR=/path/to/music +GOFI_QUALITY=9 +GOFI_LOG_LEVEL=info +``` + +**Note**: Command-line flags always take precedence over environment variables. + +Alternatively, you can set these as environment variables: + +```bash +export SPOTIFY_CLIENT_ID=your_spotify_client_id_here +export SPOTIFY_CLIENT_SECRET=your_spotify_client_secret_here +export DEEZER_ARL=your_deezer_arl_here +``` + +### Authenticating with Services + +#### Spotify Authentication + +Before using Spotify features, you need to authenticate: + +```bash +./gofi auth spotify +``` + +This will start the OAuth flow and open a browser window for you to authorize the application. + +#### Deezer Authentication + +To set up Deezer authentication automatically: + +```bash +./gofi auth deezer +``` + +This will find and save your ARL token from your browser cookies. + +### Basic Commands + +#### Downloading with URLs + +GoFi automatically detects whether you're using a Spotify or Deezer URL: + +```bash +# Download from Spotify URLs (requires Spotify authentication) +./gofi download https://open.spotify.com/track/2YarjDYjBJuH63dUIh9OWv +./gofi download https://open.spotify.com/album/1DFixLWuPkv3KT3TnV35m3 +./gofi download https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M + +# Download from Deezer URLs (no Spotify auth needed) +./gofi download https://www.deezer.com/track/3135556 +./gofi download https://www.deezer.com/album/302127 +./gofi download https://www.deezer.com/playlist/1234567890 + +# Specify output directory and quality +./gofi -o ~/Music/Downloads -q 9 download https://www.deezer.com/playlist/13872511521 +``` + +For Spotify URLs, the app will search for matching content on Deezer and download it. +For Deezer URLs, content is downloaded directly. + +### User Interface + +GoFi features a polished command-line interface with: + +- **Progress Bars**: Real-time download progress with speed and ETA +- **Colored Output**: Easy-to-read color-coded messages +- **Status Icons**: Visual feedback with ✓ for success, ✗ for errors, ℹ for info +- **Smart Display**: Clean, organized output that doesn't clutter your terminal +- **Download Summary**: Clear summary of successful and failed downloads + +### Quality Settings + +GoFi supports different quality settings for music downloads: + +- **FLAC (9)**: Lossless audio format, highest quality +- **MP3 320kbps (3)**: High quality MP3 (default) +- **MP3 128kbps (1)**: Standard quality MP3 + +Specify quality with the `-q` flag: + +```bash +./gofi -q 9 download https://open.spotify.com/track/2YarjDYjBJuH63dUIh9OWv +``` + +### Advanced Options + +- `-o, --output`: Set the download directory +- `-q, --quality`: Set the audio quality (1, 3, or 9) +- `-l, --log-level`: Set the log level (debug, info, warn, error) + +```bash +./gofi -o ./my-music -q 9 -l debug download https://open.spotify.com/album/1DFixLWuPkv3KT3TnV35m3 +``` + +## Troubleshooting + +- If you get Spotify authentication errors, run `./gofi auth spotify` to re-authenticate +- If downloads fail, try increasing the log level with `-l debug` for more information +- ARL tokens expire periodically, so you may need to obtain a new one if you haven't used the tool in a while + +## Contributing Contributions to this project are welcome. If you find any bugs or have suggestions for new features, please open an issue or submit a pull request. ## License -This project is licensed under the [MIT License](LICENSE). +This project is licensed under the [MIT License](LICENSE). \ No newline at end of file diff --git a/api/api_test.go b/api/api_test.go index 4b37d19..dcac5f5 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -1,11 +1,11 @@ package api import ( - "os" "strconv" "strings" "testing" + "github.com/d-fi/GoFi/internal/auth" "github.com/d-fi/GoFi/request" "github.com/stretchr/testify/assert" ) @@ -15,16 +15,41 @@ const ( ALB_ID = "302127" // Discovery by Daft Punk ) +var testingEnabled bool + func init() { // Initialize the Deezer API for all tests - arl := os.Getenv("DEEZER_ARL") - _, err := request.InitDeezerAPI(arl) + // Try to get ARL from various sources (env, browser cookies, etc.) + arl, err := auth.GetARLToken() + if err != nil { + // Skip tests if no ARL is available from any source + testingEnabled = false + return + } + + // Try to initialize the API and validate the token + _, err = request.InitDeezerAPI(arl) if err != nil { - panic("Failed to initialize Deezer API: " + err.Error()) + // ARL found but invalid - skip tests + testingEnabled = false + return } + + // Try a simple API call to validate the token works + _, err = GetUser() + if err != nil && strings.Contains(err.Error(), "AUTH_REQUIRED") { + // Token is invalid or expired + testingEnabled = false + return + } + + testingEnabled = true } func TestGetUser(t *testing.T) { + if !testingEnabled { + t.Skip("Skipping test: No valid ARL token available") + } response, err := GetUser() assert.NoError(t, err) assert.NotEmpty(t, response.BlogName) @@ -34,6 +59,9 @@ func TestGetUser(t *testing.T) { } func TestGetTrackInfo(t *testing.T) { + if !testingEnabled { + t.Skip("Skipping test: No valid ARL token available") + } response, err := GetTrackInfo(SNG_ID) assert.NoError(t, err) assert.Equal(t, SNG_ID, response.SNG_ID) @@ -43,6 +71,9 @@ func TestGetTrackInfo(t *testing.T) { } func TestGetTrackInfoPublicApi(t *testing.T) { + if !testingEnabled { + t.Skip("Skipping test: No valid ARL token available") + } response, err := GetTrackInfoPublicApi(SNG_ID) assert.NoError(t, err) assert.Equal(t, SNG_ID, strconv.Itoa(response.ID)) @@ -51,6 +82,9 @@ func TestGetTrackInfoPublicApi(t *testing.T) { } func TestGetLyrics(t *testing.T) { + if !testingEnabled { + t.Skip("Skipping test: No valid ARL token available") + } response, err := GetLyrics(SNG_ID) assert.NoError(t, err) assert.NotNil(t, response.LYRICS_ID) @@ -59,6 +93,9 @@ func TestGetLyrics(t *testing.T) { } func TestGetAlbumInfo(t *testing.T) { + if !testingEnabled { + t.Skip("Skipping test: No valid ARL token available") + } response, err := GetAlbumInfo(ALB_ID) assert.NoError(t, err) assert.Equal(t, ALB_ID, response.ALB_ID) @@ -67,6 +104,9 @@ func TestGetAlbumInfo(t *testing.T) { } func TestGetAlbumInfoPublicApi(t *testing.T) { + if !testingEnabled { + t.Skip("Skipping test: No valid ARL token available") + } response, err := GetAlbumInfoPublicApi(ALB_ID) assert.NoError(t, err) assert.Equal(t, ALB_ID, strconv.Itoa(response.ID)) @@ -75,6 +115,9 @@ func TestGetAlbumInfoPublicApi(t *testing.T) { } func TestGetAlbumTracks(t *testing.T) { + if !testingEnabled { + t.Skip("Skipping test: No valid ARL token available") + } response, err := GetAlbumTracks(ALB_ID) assert.NoError(t, err) assert.Equal(t, 14, response.Count) @@ -82,6 +125,9 @@ func TestGetAlbumTracks(t *testing.T) { } func TestGetPlaylistInfo(t *testing.T) { + if !testingEnabled { + t.Skip("Skipping test: No valid ARL token available") + } PLAYLIST_ID := "4523119944" response, err := GetPlaylistInfo(PLAYLIST_ID) assert.NoError(t, err) @@ -91,6 +137,9 @@ func TestGetPlaylistInfo(t *testing.T) { } func TestGetPlaylistTracks(t *testing.T) { + if !testingEnabled { + t.Skip("Skipping test: No valid ARL token available") + } PLAYLIST_ID := "4523119944" response, err := GetPlaylistTracks(PLAYLIST_ID) assert.NoError(t, err) @@ -99,6 +148,9 @@ func TestGetPlaylistTracks(t *testing.T) { } func TestGetArtistInfo(t *testing.T) { + if !testingEnabled { + t.Skip("Skipping test: No valid ARL token available") + } ART_ID := "13" response, err := GetArtistInfo(ART_ID) assert.NoError(t, err) @@ -107,6 +159,9 @@ func TestGetArtistInfo(t *testing.T) { } func TestGetDiscography(t *testing.T) { + if !testingEnabled { + t.Skip("Skipping test: No valid ARL token available") + } ART_ID := "13" response, err := GetDiscography(ART_ID, 10) assert.NoError(t, err) @@ -115,6 +170,9 @@ func TestGetDiscography(t *testing.T) { } func TestGetProfile(t *testing.T) { + if !testingEnabled { + t.Skip("Skipping test: No valid ARL token available") + } USER_ID := "2064440442" response, err := GetProfile(USER_ID) assert.NoError(t, err) @@ -123,6 +181,9 @@ func TestGetProfile(t *testing.T) { } func TestSearchAlternative(t *testing.T) { + if !testingEnabled { + t.Skip("Skipping test: No valid ARL token available") + } ARTIST := "Eminem" TRACK := "The Real Slim Shady" response, err := SearchAlternative(ARTIST, TRACK, 10) @@ -132,6 +193,9 @@ func TestSearchAlternative(t *testing.T) { } func TestSearchMusic(t *testing.T) { + if !testingEnabled { + t.Skip("Skipping test: No valid ARL token available") + } QUERY := "Eminem" response, err := SearchMusic(QUERY, 1, "TRACK", "ALBUM", "ARTIST") assert.NoError(t, err) @@ -142,6 +206,9 @@ func TestSearchMusic(t *testing.T) { } func TestGetChannelList(t *testing.T) { + if !testingEnabled { + t.Skip("Skipping test: No valid ARL token available") + } response, err := GetChannelList() assert.NoError(t, err) assert.Greater(t, response.Count, 0) @@ -149,6 +216,9 @@ func TestGetChannelList(t *testing.T) { } func TestGetShowInfo(t *testing.T) { + if !testingEnabled { + t.Skip("Skipping test: No valid ARL token available") + } response, err := GetShowInfo("338532", 10, 0) assert.NoError(t, err) assert.Equal(t, "201952", response.Data.LabelID) diff --git a/api/spotify_deezer_match.go b/api/spotify_deezer_match.go new file mode 100644 index 0000000..b1fbb89 --- /dev/null +++ b/api/spotify_deezer_match.go @@ -0,0 +1,164 @@ +package api + +import ( + "fmt" + "strings" + "time" + + "github.com/d-fi/GoFi/internal/models" + "github.com/d-fi/GoFi/logger" + "github.com/d-fi/GoFi/types" +) + +// SearchTrackOnDeezer searches for a Spotify track on Deezer +func SearchTrackOnDeezer(track *models.Track) (types.TrackType, error) { + if track == nil { + return types.TrackType{}, fmt.Errorf("cannot search for nil track") + } + + // Try searching by ISRC first (most accurate) + if track.ISRC != "" { + logger.Debug("Searching for track by ISRC: %s", track.ISRC) + searchResult, err := SearchMusic("isrc:"+track.ISRC, 1) + if err == nil && len(searchResult.TRACK.Data) > 0 { + trackID := fmt.Sprint(searchResult.TRACK.Data[0].SNG_ID) + logger.Debug("Found track by ISRC match: %s", trackID) + return GetTrackInfo(trackID) + } + logger.Debug("ISRC search failed or returned no results, falling back to metadata search") + } + + // Clean up artist and track names for more accurate searching + artistName := getMainArtistName(track.Artists) + trackTitle := cleanupTitle(track.Title) + + // Build a search query + query := fmt.Sprintf("artist:'%s' track:'%s'", artistName, trackTitle) + logger.Debug("Searching for track with query: %s", query) + + searchResult, err := SearchMusic(query, 5) + if err != nil { + return types.TrackType{}, fmt.Errorf("failed to search for track: %v", err) + } + + if len(searchResult.TRACK.Data) == 0 { + return types.TrackType{}, fmt.Errorf("no matching tracks found on Deezer") + } + + // Get the first result - could improve this by comparing durations, etc. + trackID := fmt.Sprint(searchResult.TRACK.Data[0].SNG_ID) + logger.Debug("Found potential track match: %s", trackID) + + return GetTrackInfo(trackID) +} + +// SearchAlbumOnDeezer searches for a Spotify album on Deezer +func SearchAlbumOnDeezer(album *models.Album) (types.AlbumType, error) { + if album == nil { + return types.AlbumType{}, fmt.Errorf("cannot search for nil album") + } + + // Try searching by UPC first (most accurate) + if album.UPC != "" { + logger.Debug("Searching for album by UPC: %s", album.UPC) + searchResult, err := SearchMusic("upc:"+album.UPC, 1, "ALBUM") + if err == nil && len(searchResult.ALBUM.Data) > 0 { + albumID := fmt.Sprint(searchResult.ALBUM.Data[0].ALB_ID) + logger.Debug("Found album by UPC match: %s", albumID) + return GetAlbumInfo(albumID) + } + logger.Debug("UPC search failed or returned no results, falling back to metadata search") + } + + // Clean up artist and album names for more accurate searching + artistName := getMainArtistName(album.Artists) + albumTitle := cleanupTitle(album.Title) + + // Build a search query + query := fmt.Sprintf("artist:'%s' album:'%s'", artistName, albumTitle) + logger.Debug("Searching for album with query: %s", query) + + searchResult, err := SearchMusic(query, 5, "ALBUM") + if err != nil { + return types.AlbumType{}, fmt.Errorf("failed to search for album: %v", err) + } + + if len(searchResult.ALBUM.Data) == 0 { + return types.AlbumType{}, fmt.Errorf("no matching albums found on Deezer") + } + + // Get the first result + albumID := fmt.Sprint(searchResult.ALBUM.Data[0].ALB_ID) + logger.Debug("Found potential album match: %s", albumID) + + return GetAlbumInfo(albumID) +} + +// MatchPlaylistTracks matches Spotify playlist tracks to Deezer tracks +func MatchPlaylistTracks(tracks []models.Track) ([]types.TrackType, error) { + if len(tracks) == 0 { + return nil, fmt.Errorf("cannot match empty track list") + } + + logger.Debug("Starting to match %d Spotify tracks to Deezer", len(tracks)) + result := make([]types.TrackType, 0, len(tracks)) + failures := 0 + + // Process each track, with rate limiting to avoid overwhelming the API + for i, track := range tracks { + // Add a small delay every few tracks to avoid rate limiting + if i > 0 && i%5 == 0 { + time.Sleep(500 * time.Millisecond) + } + + logger.Debug("Processing track %d/%d: %s - %s", i+1, len(tracks), track.Title, getMainArtistName(track.Artists)) + deezerTrack, err := SearchTrackOnDeezer(&track) + if err != nil { + logger.Error("Failed to match track %s - %s: %v", track.Title, getMainArtistName(track.Artists), err) + failures++ + continue + } + + result = append(result, deezerTrack) + logger.Debug("Successfully matched track %d/%d: %s to Deezer ID %s", + i+1, len(tracks), track.Title, deezerTrack.SNG_ID) + } + + logger.Debug("Completed track matching: %d tracks matched, %d failed", len(result), failures) + return result, nil +} + +// Helper functions + +// getMainArtistName returns the name of the main artist +func getMainArtistName(artists []models.Artist) string { + if len(artists) == 0 { + return "" + } + return artists[0].Name +} + +// cleanupTitle removes common extra text from titles like "(Remastered)" or "[Live]" +func cleanupTitle(title string) string { + // Remove content in parentheses, brackets, etc. that might differ between services + cleaned := title + + // Using simple string replacements for common patterns + if strings.Contains(cleaned, " (feat.") { + cleaned = cleaned[:strings.Index(cleaned, " (feat.")] + } + if strings.Contains(cleaned, " (ft.") { + cleaned = cleaned[:strings.Index(cleaned, " (ft.")] + } + if strings.Contains(cleaned, " (Remastered") { + cleaned = cleaned[:strings.Index(cleaned, " (Remastered")] + } + if strings.Contains(cleaned, " [") { + cleaned = cleaned[:strings.Index(cleaned, " [")] + } + if strings.Contains(cleaned, " - ") { + cleaned = cleaned[:strings.Index(cleaned, " - ")] + } + + return cleaned +} \ No newline at end of file diff --git a/cmd/gofi/cmd/auth.go b/cmd/gofi/cmd/auth.go new file mode 100644 index 0000000..8892fa0 --- /dev/null +++ b/cmd/gofi/cmd/auth.go @@ -0,0 +1,20 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// authCmd represents the auth command +var authCmd = &cobra.Command{ + Use: "auth", + Short: "Authenticate with music services", + Long: `Authenticate with supported music services like Spotify and Deezer.`, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + // Add sub-commands to auth + authCmd.AddCommand(spotifyAuthCmd) +} \ No newline at end of file diff --git a/cmd/gofi/cmd/auth_deezer.go b/cmd/gofi/cmd/auth_deezer.go new file mode 100644 index 0000000..ccca9e5 --- /dev/null +++ b/cmd/gofi/cmd/auth_deezer.go @@ -0,0 +1,97 @@ +package cmd + +import ( + "fmt" + "os" + "runtime" + + "github.com/d-fi/GoFi/internal/auth" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +var authDeezerCmd = &cobra.Command{ + Use: "deezer", + Short: "Authenticate with Deezer using browser cookies", + Long: `Authenticate with Deezer by reading the ARL cookie from your browser. +This command will automatically check Chrome, Firefox, Edge, Arc, and Safari (on macOS) +for the Deezer ARL cookie and save it to your environment.`, + Run: func(cmd *cobra.Command, args []string) { + // Try to get ARL from browser cookies + fmt.Println("🔍 Searching for Deezer ARL cookie in your browsers...") + + arl, err := auth.GetARLFromAnyBrowser() + if err != nil { + color.Red("❌ Failed to find Deezer ARL cookie: %v", err) + fmt.Println("\nPlease make sure you are logged into Deezer in one of the following browsers:") + fmt.Println(" • Chrome") + fmt.Println(" • Firefox") + fmt.Println(" • Edge") + fmt.Println(" • Arc") + if runtime.GOOS == "darwin" { + fmt.Println(" • Safari") + } + fmt.Println("\nAlternatively, you can set the DEEZER_ARL environment variable manually.") + os.Exit(1) + } + + // Validate the ARL token + if err := auth.ValidateARLToken(arl); err != nil { + color.Red("❌ Invalid ARL token: %v", err) + os.Exit(1) + } + + // Clean the ARL token before using it + cleanARL := "" + for _, r := range arl { + if r >= 32 && r <= 126 { + cleanARL += string(r) + } + } + + // Save to .env file + if err := auth.SaveARLToEnv(cleanARL); err != nil { + color.Yellow("⚠️ Failed to save ARL to .env file: %v", err) + fmt.Println("You can manually set the DEEZER_ARL environment variable.") + } else { + color.Green("✅ ARL token saved to .env file") + } + + // Also set it in the current environment + os.Setenv("DEEZER_ARL", cleanARL) + + color.Green("\n✅ Successfully authenticated with Deezer!") + fmt.Println("You can now download music from Deezer.") + + // Show a preview of the ARL (masked for security) + if len(arl) > 20 { + // Clean the display - only show printable characters + start := "" + end := "" + + // Get first 10 printable characters + for i, r := range arl { + if r >= 32 && r <= 126 { + start += string(r) + if len(start) >= 10 { + break + } + } + if i > 50 { // Don't search too far + break + } + } + + // Get last 10 characters (usually clean) + if len(arl) >= 10 { + end = arl[len(arl)-10:] + } + + fmt.Printf("\nARL Token: %s...%s\n", start, end) + } + }, +} + +func init() { + authCmd.AddCommand(authDeezerCmd) +} \ No newline at end of file diff --git a/cmd/gofi/cmd/auth_spotify.go b/cmd/gofi/cmd/auth_spotify.go new file mode 100644 index 0000000..00e6ec9 --- /dev/null +++ b/cmd/gofi/cmd/auth_spotify.go @@ -0,0 +1,68 @@ +// cmd/gofi/cmd/auth_spotify.go +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/d-fi/GoFi/internal/services/spotify" // Corrected import path + "github.com/d-fi/GoFi/logger" + "github.com/spf13/cobra" +) + +// spotifyAuthCmd represents the command to authenticate with Spotify +var spotifyAuthCmd = &cobra.Command{ + Use: "spotify", + Short: "Authenticate GoFi with your Spotify account", + Long: `Starts the OAuth2 flow to authorize GoFi to access your Spotify account. +You will be prompted to open a URL in your browser to grant permission. +Requires SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET environment variables to be set.`, + Run: func(cmd *cobra.Command, args []string) { + clientID := os.Getenv("SPOTIFY_CLIENT_ID") + clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET") + + if clientID == "" || clientSecret == "" { + logger.Fatal("Error: SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET environment variables must be set.") + return // Redundant after Fatal, but good practice + } + + cfg := spotify.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + } + + authService, err := spotify.NewAuthService(cfg) + if err != nil { + logger.Fatal("Error creating Spotify auth service: %v", err) + } + + logger.Info("Starting Spotify authentication process...") + // Use context.Background() for this CLI command execution context + client, err := authService.StartAuthentication(context.Background()) + if err != nil { + logger.Fatal("Spotify authentication failed: %v", err) + } + + if client != nil { + // Optionally, verify the client works by making a simple API call + user, err := client.CurrentUser(context.Background()) + if err != nil { + logger.Warn("Could not verify authentication by fetching user: %v", err) + fmt.Println("Authentication process completed, but verification failed. Token might be stored.") + } else { + fmt.Printf("\nSuccessfully authenticated with Spotify as %s (%s)!\n", user.DisplayName, user.ID) + fmt.Println("Authentication token saved successfully.") + } + } else { + // This case should ideally be caught by the error above, but added for completeness + logger.Fatal("Authentication process completed, but no valid client was returned.") + } + }, +} + +// init function is already called from auth.go +// No need to add logging or registration here +func init() { + // Empty init function - registration handled in auth.go +} \ No newline at end of file diff --git a/cmd/gofi/cmd/download.go b/cmd/gofi/cmd/download.go new file mode 100644 index 0000000..9cc55ea --- /dev/null +++ b/cmd/gofi/cmd/download.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" +) + +// downloadCmd represents the download command +var downloadCmd = &cobra.Command{ + Use: "download [url]", + Short: "Download music from a URL", + Long: `Download music from Deezer or Spotify URL. +Supports tracks, albums, and playlists from both services. + +For Spotify URLs, content will be searched on Deezer and downloaded. +For Deezer URLs, content will be downloaded directly. + +Examples: + gofi download https://open.spotify.com/track/2YarjDYjBJuH63dUIh9OWv + gofi download https://open.spotify.com/album/1DFixLWuPkv3KT3TnV35m3 + gofi download https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M + gofi download https://www.deezer.com/track/1234567 + gofi download https://www.deezer.com/album/1234567 + gofi download https://www.deezer.com/playlist/1234567890`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + url := args[0] + + // Use the improved download handler with better UI + err := downloadHandlerImproved(url, downloadPath, quality) + if err != nil { + // Error is already printed by the handler + os.Exit(1) + } + }, +} \ No newline at end of file diff --git a/cmd/gofi/cmd/download_handler.go b/cmd/gofi/cmd/download_handler.go new file mode 100644 index 0000000..706cf2a --- /dev/null +++ b/cmd/gofi/cmd/download_handler.go @@ -0,0 +1,458 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/d-fi/GoFi/api" + "github.com/d-fi/GoFi/download" + "github.com/d-fi/GoFi/internal/models" + "github.com/d-fi/GoFi/internal/services/spotify" + internalutils "github.com/d-fi/GoFi/internal/utils" + "github.com/d-fi/GoFi/types" + "github.com/d-fi/GoFi/utils" +) + +// downloadHandler processes downloads based on URL type +func downloadHandler(url string, downloadPath string, quality int) error { + ctx := context.Background() + + // Parse the URL to identify its type + parsedInfo, err := internalutils.ParseMusicURL(url) + if err != nil { + return fmt.Errorf("failed to parse URL: %v", err) + } + + // Handle Spotify URLs + if parsedInfo.Source == "spotify" { + return handleSpotifyDownload(ctx, parsedInfo, downloadPath, quality) + } + + // Handle Deezer URLs directly + if parsedInfo.Source == "deezer" { + return handleDeezerDownload(ctx, parsedInfo, downloadPath, quality) + } + + return fmt.Errorf("unsupported URL source: %s", parsedInfo.Source) +} + +// handleSpotifyDownload processes Spotify URLs and downloads content from Deezer +func handleSpotifyDownload(ctx context.Context, parsedInfo *internalutils.ParsedURLInfo, downloadPath string, quality int) error { + // Get the Spotify client + client, _ := getAuthenticatedSpotifyClient(ctx) + if client == nil { + return fmt.Errorf("could not get authenticated Spotify client - run 'gofi auth spotify' first") + } + + // Create Spotify service + spotifyService := spotify.NewSpotifyService(client) + if spotifyService == nil { + return fmt.Errorf("failed to initialize Spotify service") + } + + // Process based on content type + switch parsedInfo.Type { + case internalutils.SpotifyTrack: + return handleSpotifyTrack(ctx, spotifyService, parsedInfo.ID, downloadPath, quality) + + case internalutils.SpotifyAlbum: + return handleSpotifyAlbum(ctx, spotifyService, parsedInfo.ID, downloadPath, quality) + + case internalutils.SpotifyPlaylist: + return handleSpotifyPlaylist(ctx, spotifyService, parsedInfo.ID, downloadPath, quality) + + default: + return fmt.Errorf("unsupported Spotify content type: %s", parsedInfo.Type) + } +} + +// handleDeezerDownload processes Deezer URLs and downloads content directly +func handleDeezerDownload(ctx context.Context, parsedInfo *internalutils.ParsedURLInfo, downloadPath string, quality int) error { + // Process based on content type + switch parsedInfo.Type { + case internalutils.DeezerTrack: + return handleDeezerTrack(parsedInfo.ID, downloadPath, quality) + + case internalutils.DeezerAlbum: + return handleDeezerAlbum(parsedInfo.ID, downloadPath, quality) + + case internalutils.DeezerPlaylist: + return handleDeezerPlaylist(parsedInfo.ID, downloadPath, quality) + + default: + return fmt.Errorf("unsupported Deezer content type: %s", parsedInfo.Type) + } +} + +// handleDeezerTrack handles downloading a single Deezer track +func handleDeezerTrack(id string, downloadPath string, quality int) error { + fmt.Printf("Getting track info from Deezer... ") + track, err := api.GetTrackInfo(id) + if err != nil { + return fmt.Errorf("failed to get track info from Deezer: %v", err) + } + fmt.Printf("✓\n") + + // Print track info + fmt.Printf("\nTrack info:\n") + fmt.Printf(" Title: %s\n", track.SNG_TITLE) + fmt.Printf(" Artist: %s\n", track.ART_NAME) + fmt.Printf(" Album: %s\n", track.ALB_TITLE) + fmt.Printf(" Quality: %d\n", quality) + fmt.Printf("\nDownloading track...\n") + + // Create a folder with the artist name + artistFolder := filepath.Join(downloadPath, track.ART_NAME) + + // Custom filename for the track: Artist - Title + customFilename := fmt.Sprintf("%s - %s", track.ART_NAME, track.SNG_TITLE) + + // Download the track + return downloadTrack(track, artistFolder, quality, customFilename) +} + +// handleDeezerAlbum handles downloading a Deezer album +func handleDeezerAlbum(id string, downloadPath string, quality int) error { + fmt.Printf("Getting album info from Deezer... ") + album, err := api.GetAlbumInfo(id) + if err != nil { + return fmt.Errorf("failed to get album info from Deezer: %v", err) + } + fmt.Printf("✓\n") + + // Print album info + fmt.Printf("\nAlbum info:\n") + fmt.Printf(" Title: %s\n", album.ALB_TITLE) + fmt.Printf(" Artist: %s\n", album.ART_NAME) + fmt.Printf(" Quality: %d\n", quality) + fmt.Printf("\nDownloading album...\n") + + // Create a folder for the album + albumPath := filepath.Join(downloadPath, album.ALB_TITLE) + + // Get album tracks + albumTracks, err := api.GetAlbumTracks(id) + if err != nil { + return fmt.Errorf("failed to get album tracks from Deezer: %v", err) + } + + // Download the tracks + var lastError error + for _, track := range albumTracks.Data { + trackInfo, err := api.GetTrackInfo(fmt.Sprint(track.SNG_ID)) + if err != nil { + fmt.Printf("Error getting info for track %s: %v\n", track.SNG_TITLE, err) + lastError = err + continue + } + + // Custom filename for the track: Artist - Title + customFilename := fmt.Sprintf("%s - %s", trackInfo.ART_NAME, trackInfo.SNG_TITLE) + + err = downloadTrack(trackInfo, albumPath, quality, customFilename) + if err != nil { + fmt.Printf("Error downloading track %s: %v\n", track.SNG_TITLE, err) + lastError = err + } + } + + if lastError != nil { + return fmt.Errorf("some tracks failed to download") + } + return nil +} + +// handleDeezerPlaylist handles downloading a Deezer playlist +func handleDeezerPlaylist(id string, downloadPath string, quality int) error { + fmt.Printf("Getting playlist info from Deezer... ") + playlist, err := api.GetPlaylistInfo(id) + if err != nil { + return fmt.Errorf("failed to get playlist info from Deezer: %v", err) + } + fmt.Printf("✓\n") + + // Get playlist tracks + tracks, err := api.GetPlaylistTracks(id) + if err != nil { + return fmt.Errorf("failed to get playlist tracks from Deezer: %v", err) + } + + fmt.Printf("Found playlist: %s (%d tracks)\n", playlist.Title, len(tracks.Data)) + + // Create a folder for the playlist using just the playlist name + playlistPath := filepath.Join(downloadPath, playlist.Title) + + // Download the tracks + total := len(tracks.Data) + succeeded := 0 + failed := 0 + + for i, track := range tracks.Data { + fmt.Printf("[%d/%d] Processing: %s by %s... ", + i+1, total, track.SNG_TITLE, track.ART_NAME) + + trackInfo, err := api.GetTrackInfo(fmt.Sprint(track.SNG_ID)) + if err != nil { + fmt.Printf("✗\n") + fmt.Printf(" Failed to get track info: %v\n", err) + failed++ + continue + } + + // Custom filename for the track: Artist - Title + customFilename := fmt.Sprintf("%s - %s", trackInfo.ART_NAME, trackInfo.SNG_TITLE) + + err = downloadTrack(trackInfo, playlistPath, quality, customFilename) + if err != nil { + fmt.Printf("✗\n") + fmt.Printf(" Error downloading: %v\n", err) + failed++ + continue + } + + succeeded++ + } + + fmt.Printf("\nDownload summary: %d succeeded, %d failed out of %d total\n", + succeeded, failed, total) + + if failed > 0 { + return fmt.Errorf("%d out of %d tracks failed to download", failed, total) + } + return nil +} + +// handleSpotifyTrack handles downloading a single Spotify track +func handleSpotifyTrack(ctx context.Context, spotifyService *spotify.SpotifyService, id string, downloadPath string, quality int) error { + fmt.Printf("Getting track info from Spotify... ") + track, err := spotifyService.FetchTrack(ctx, id) + if err != nil { + return fmt.Errorf("failed to fetch track from Spotify: %v", err) + } + fmt.Printf("✓\n") + + // Find matching track on Deezer + fmt.Printf("Searching for track on Deezer... ") + deezerTrack, err := api.SearchTrackOnDeezer(track) + if err != nil { + return fmt.Errorf("failed to find track on Deezer: %v", err) + } + fmt.Printf("✓\n") + + // Print found track info + fmt.Printf("\nFound track on Deezer:\n") + fmt.Printf(" Title: %s\n", deezerTrack.SNG_TITLE) + fmt.Printf(" Artist: %s\n", deezerTrack.ART_NAME) + fmt.Printf(" Album: %s\n", deezerTrack.ALB_TITLE) + fmt.Printf(" Quality: %d\n", quality) + fmt.Printf("\nDownloading track...\n") + + // Create a folder with the artist name + artistFolder := filepath.Join(downloadPath, deezerTrack.ART_NAME) + + // Custom filename for the track: Artist - Title + customFilename := fmt.Sprintf("%s - %s", deezerTrack.ART_NAME, deezerTrack.SNG_TITLE) + + // Download the track + return downloadTrack(deezerTrack, artistFolder, quality, customFilename) +} + +// handleSpotifyAlbum handles downloading a Spotify album +func handleSpotifyAlbum(ctx context.Context, spotifyService *spotify.SpotifyService, id string, downloadPath string, quality int) error { + fmt.Printf("Getting album info from Spotify... ") + album, tracks, err := spotifyService.FetchAlbum(ctx, id) + if err != nil { + return fmt.Errorf("failed to fetch album from Spotify: %v", err) + } + fmt.Printf("✓\n") + + fmt.Printf("Found album: %s by %s (%d tracks)\n", + album.Title, + joinArtistNames(album.Artists), + len(tracks)) + + // Find matching album on Deezer + fmt.Printf("Searching for album on Deezer... ") + deezerAlbum, err := api.SearchAlbumOnDeezer(album) + if err != nil { + fmt.Printf("✗\n") + fmt.Printf("Could not find album on Deezer. Trying to match individual tracks...\n") + return downloadSpotifyTracksIndividually(tracks, downloadPath, quality, "") + } + fmt.Printf("✓\n") + + // Print found album info + fmt.Printf("\nFound album on Deezer:\n") + fmt.Printf(" Title: %s\n", deezerAlbum.ALB_TITLE) + fmt.Printf(" Artist: %s\n", deezerAlbum.ART_NAME) + fmt.Printf(" Quality: %d\n", quality) + fmt.Printf("\nDownloading album...\n") + + // Create a folder for the album + albumPath := filepath.Join(downloadPath, deezerAlbum.ALB_TITLE) + + // Get album tracks + albumTracks, err := api.GetAlbumTracks(fmt.Sprint(deezerAlbum.ALB_ID)) + if err != nil { + return fmt.Errorf("failed to get album tracks from Deezer: %v", err) + } + + // Download the tracks + var lastError error + for _, track := range albumTracks.Data { + trackInfo, err := api.GetTrackInfo(fmt.Sprint(track.SNG_ID)) + if err != nil { + fmt.Printf("Error getting info for track %s: %v\n", track.SNG_TITLE, err) + lastError = err + continue + } + + // Custom filename for the track: Artist - Title + customFilename := fmt.Sprintf("%s - %s", trackInfo.ART_NAME, trackInfo.SNG_TITLE) + + err = downloadTrack(trackInfo, albumPath, quality, customFilename) + if err != nil { + fmt.Printf("Error downloading track %s: %v\n", track.SNG_TITLE, err) + lastError = err + } + } + + if lastError != nil { + return fmt.Errorf("some tracks failed to download") + } + return nil +} + +// handleSpotifyPlaylist handles downloading a Spotify playlist +func handleSpotifyPlaylist(ctx context.Context, spotifyService *spotify.SpotifyService, id string, downloadPath string, quality int) error { + fmt.Printf("Getting playlist info from Spotify... ") + playlist, tracks, err := spotifyService.FetchPlaylist(ctx, id) + if err != nil { + return fmt.Errorf("failed to fetch playlist from Spotify: %v", err) + } + fmt.Printf("✓\n") + + fmt.Printf("Found playlist: %s by %s (%d tracks)\n", + playlist.Title, + playlist.OwnerName, + len(tracks)) + + // Create a folder for the playlist using just the playlist name + playlistPath := filepath.Join(downloadPath, playlist.Title) + + return downloadSpotifyTracksIndividually(tracks, playlistPath, quality, "") +} + +// downloadSpotifyTracksIndividually searches for and downloads each track individually +func downloadSpotifyTracksIndividually(tracks []models.Track, downloadPath string, quality int, playlistName string) error { + total := len(tracks) + succeeded := 0 + failed := 0 + + fmt.Printf("Matching %d tracks from Spotify to Deezer...\n", total) + + for i, track := range tracks { + // Add a small delay to avoid overwhelming the Deezer API + if i > 0 && i%3 == 0 { + time.Sleep(1 * time.Second) + } + + fmt.Printf("[%d/%d] Processing: %s by %s... ", + i+1, total, track.Title, joinArtistNames(track.Artists)) + + deezerTrack, err := api.SearchTrackOnDeezer(&track) + if err != nil { + fmt.Printf("✗\n") + fmt.Printf(" Failed to find on Deezer: %v\n", err) + failed++ + continue + } + fmt.Printf("✓\n") + + // Always use "Artist - Title" format for all tracks + customFilename := fmt.Sprintf("%s - %s", deezerTrack.ART_NAME, deezerTrack.SNG_TITLE) + + // Download the track + err = downloadTrack(deezerTrack, downloadPath, quality, customFilename) + if err != nil { + fmt.Printf(" Error downloading: %v\n", err) + failed++ + continue + } + + succeeded++ + } + + fmt.Printf("\nDownload summary: %d succeeded, %d failed out of %d total\n", + succeeded, failed, total) + + if failed > 0 { + return fmt.Errorf("%d out of %d tracks failed to download", failed, total) + } + return nil +} + +// downloadTrack downloads a single track from Deezer +func downloadTrack(track types.TrackType, downloadPath string, quality int, customFilename string) error { + // Determine cover size based on quality + // For FLAC (quality=9), use 1000 + // For MP3 320 (quality=3), use 500 + // For MP3 128 (quality=1), use 500 + coverSize := 500 + if quality == 9 { + coverSize = 1000 + } + + // Create the directory if it doesn't exist + if err := os.MkdirAll(downloadPath, 0755); err != nil { + return fmt.Errorf("failed to create download directory: %v", err) + } + + // Create download options + options := download.DownloadTrackOptions{ + SngID: fmt.Sprint(track.SNG_ID), + Quality: quality, + CoverSize: coverSize, // Use the appropriate cover size based on quality + SaveToDir: downloadPath, + Filename: utils.SanitizeFileName(customFilename), // Use a custom filename without the ID suffix + OnProgress: func(progress float64, _, _ int64) { + // Simple progress indicator + if int(progress)%10 == 0 { + fmt.Printf(".") + } + }, + } + + // Execute download + filePath, err := download.DownloadTrack(options) + if err != nil { + return err + } + + fmt.Printf(" Saved to: %s\n", filePath) + return nil +} + +// joinArtistNames combines artist names into a comma-separated string +func joinArtistNames(artists []models.Artist) string { + if len(artists) == 0 { + return "Unknown Artist" + } + + names := make([]string, 0, len(artists)) + for _, artist := range artists { + if artist.Name != "" { + names = append(names, artist.Name) + } + } + + if len(names) == 0 { + return "Unknown Artist" + } + + return strings.Join(names, ", ") +} \ No newline at end of file diff --git a/cmd/gofi/cmd/download_handler_improved.go b/cmd/gofi/cmd/download_handler_improved.go new file mode 100644 index 0000000..f57a4bc --- /dev/null +++ b/cmd/gofi/cmd/download_handler_improved.go @@ -0,0 +1,481 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/d-fi/GoFi/api" + "github.com/d-fi/GoFi/download" + "github.com/d-fi/GoFi/internal/models" + "github.com/d-fi/GoFi/internal/services/spotify" + "github.com/d-fi/GoFi/internal/ui" + internalutils "github.com/d-fi/GoFi/internal/utils" + "github.com/d-fi/GoFi/types" + "github.com/d-fi/GoFi/utils" +) + +var display = ui.NewDisplayManager() + +// downloadHandlerImproved processes downloads with improved UI +func downloadHandlerImproved(url string, downloadPath string, quality int) error { + ctx := context.Background() + + // Parse the URL to identify its type + parsedInfo, err := internalutils.ParseMusicURL(url) + if err != nil { + display.PrintError("Failed to parse URL: %v", err) + return err + } + + // Handle Spotify URLs + if parsedInfo.Source == "spotify" { + return handleSpotifyDownloadImproved(ctx, parsedInfo, downloadPath, quality) + } + + // Handle Deezer URLs directly + if parsedInfo.Source == "deezer" { + return handleDeezerDownloadImproved(ctx, parsedInfo, downloadPath, quality) + } + + display.PrintError("Unsupported URL source: %s", parsedInfo.Source) + return fmt.Errorf("unsupported URL source: %s", parsedInfo.Source) +} + +// handleSpotifyDownloadImproved processes Spotify URLs with improved UI +func handleSpotifyDownloadImproved(ctx context.Context, parsedInfo *internalutils.ParsedURLInfo, downloadPath string, quality int) error { + // Get the Spotify client + client, _ := getAuthenticatedSpotifyClient(ctx) + if client == nil { + display.PrintError("Could not get authenticated Spotify client") + display.PrintInfo("Run 'gofi auth spotify' to authenticate first") + return fmt.Errorf("could not get authenticated Spotify client") + } + + // Create Spotify service + spotifyService := spotify.NewSpotifyService(client) + if spotifyService == nil { + display.PrintError("Failed to initialize Spotify service") + return fmt.Errorf("failed to initialize Spotify service") + } + + // Process based on content type + switch parsedInfo.Type { + case internalutils.SpotifyTrack: + return handleSpotifyTrackImproved(ctx, spotifyService, parsedInfo.ID, downloadPath, quality) + + case internalutils.SpotifyAlbum: + return handleSpotifyAlbumImproved(ctx, spotifyService, parsedInfo.ID, downloadPath, quality) + + case internalutils.SpotifyPlaylist: + return handleSpotifyPlaylistImproved(ctx, spotifyService, parsedInfo.ID, downloadPath, quality) + + default: + display.PrintError("Unsupported Spotify content type: %s", parsedInfo.Type) + return fmt.Errorf("unsupported Spotify content type: %s", parsedInfo.Type) + } +} + +// handleDeezerDownloadImproved processes Deezer URLs with improved UI +func handleDeezerDownloadImproved(ctx context.Context, parsedInfo *internalutils.ParsedURLInfo, downloadPath string, quality int) error { + // Process based on content type + switch parsedInfo.Type { + case internalutils.DeezerTrack: + return handleDeezerTrackImproved(parsedInfo.ID, downloadPath, quality) + + case internalutils.DeezerAlbum: + return handleDeezerAlbumImproved(parsedInfo.ID, downloadPath, quality) + + case internalutils.DeezerPlaylist: + return handleDeezerPlaylistImproved(parsedInfo.ID, downloadPath, quality) + + default: + display.PrintError("Unsupported Deezer content type: %s", parsedInfo.Type) + return fmt.Errorf("unsupported Deezer content type: %s", parsedInfo.Type) + } +} + +// handleSpotifyTrackImproved handles downloading a single Spotify track with improved UI +func handleSpotifyTrackImproved(ctx context.Context, spotifyService *spotify.SpotifyService, id string, downloadPath string, quality int) error { + display.PrintHeader("Spotify Track Download") + + // Fetch track from Spotify + display.PrintSearching("Spotify for track info") + track, err := spotifyService.FetchTrack(ctx, id) + if err != nil { + display.PrintSearchResult(false) + return fmt.Errorf("failed to fetch track from Spotify: %v", err) + } + display.PrintSearchResult(true) + + // Find matching track on Deezer + display.PrintSearching("Deezer for matching track") + deezerTrack, err := api.SearchTrackOnDeezer(track) + if err != nil { + display.PrintSearchResult(false) + return fmt.Errorf("failed to find track on Deezer: %v", err) + } + display.PrintSearchResult(true) + + // Print track info + display.PrintTrackInfo(deezerTrack.SNG_TITLE, deezerTrack.ART_NAME, deezerTrack.ALB_TITLE, quality) + + // Create a folder with the artist name + artistFolder := filepath.Join(downloadPath, deezerTrack.ART_NAME) + + // Custom filename for the track: Artist - Title + customFilename := fmt.Sprintf("%s - %s", deezerTrack.ART_NAME, deezerTrack.SNG_TITLE) + + // Download the track + return downloadTrackImproved(deezerTrack, artistFolder, quality, customFilename) +} + +// handleDeezerTrackImproved handles downloading a single Deezer track with improved UI +func handleDeezerTrackImproved(id string, downloadPath string, quality int) error { + display.PrintHeader("Deezer Track Download") + + display.PrintSearching("Deezer for track info") + track, err := api.GetTrackInfo(id) + if err != nil { + display.PrintSearchResult(false) + return fmt.Errorf("failed to get track info from Deezer: %v", err) + } + display.PrintSearchResult(true) + + // Print track info + display.PrintTrackInfo(track.SNG_TITLE, track.ART_NAME, track.ALB_TITLE, quality) + + // Create a folder with the artist name + artistFolder := filepath.Join(downloadPath, track.ART_NAME) + + // Custom filename for the track: Artist - Title + customFilename := fmt.Sprintf("%s - %s", track.ART_NAME, track.SNG_TITLE) + + // Download the track + return downloadTrackImproved(track, artistFolder, quality, customFilename) +} + +// handleSpotifyAlbumImproved handles downloading a Spotify album with improved UI +func handleSpotifyAlbumImproved(ctx context.Context, spotifyService *spotify.SpotifyService, id string, downloadPath string, quality int) error { + display.PrintHeader("Spotify Album Download") + + display.PrintSearching("Spotify for album info") + album, tracks, err := spotifyService.FetchAlbum(ctx, id) + if err != nil { + display.PrintSearchResult(false) + return fmt.Errorf("failed to fetch album from Spotify: %v", err) + } + display.PrintSearchResult(true) + + artistName := joinArtistNames(album.Artists) + display.PrintAlbumInfo(album.Title, artistName, len(tracks), quality) + + // Find matching album on Deezer + display.PrintSearching("Deezer for matching album") + deezerAlbum, err := api.SearchAlbumOnDeezer(album) + if err != nil { + display.PrintSearchResult(false) + display.PrintWarning("Could not find album on Deezer. Trying to match individual tracks...") + return downloadSpotifyTracksIndividuallyImproved(tracks, downloadPath, quality, "") + } + display.PrintSearchResult(true) + + // Create a folder for the album + albumPath := filepath.Join(downloadPath, deezerAlbum.ALB_TITLE) + + // Get album tracks + albumTracks, err := api.GetAlbumTracks(fmt.Sprint(deezerAlbum.ALB_ID)) + if err != nil { + display.PrintError("Failed to get album tracks from Deezer: %v", err) + return fmt.Errorf("failed to get album tracks from Deezer: %v", err) + } + + // Download the tracks + total := len(albumTracks.Data) + succeeded := 0 + failed := 0 + + display.PrintInfo("Starting download of %d tracks...", total) + fmt.Println() + + for i, track := range albumTracks.Data { + trackInfo, err := api.GetTrackInfo(fmt.Sprint(track.SNG_ID)) + if err != nil { + display.PrintError("[%d/%d] Failed to get info for: %s", i+1, total, track.SNG_TITLE) + failed++ + continue + } + + // Custom filename for the track: Artist - Title + customFilename := fmt.Sprintf("%s - %s", trackInfo.ART_NAME, trackInfo.SNG_TITLE) + + display.PrintInfo("[%d/%d] Downloading: %s", i+1, total, customFilename) + err = downloadTrackImproved(trackInfo, albumPath, quality, customFilename) + if err != nil { + display.PrintError("Failed: %v", err) + failed++ + } else { + succeeded++ + } + } + + display.PrintDownloadSummary(succeeded, failed, total) + + if failed > 0 { + return fmt.Errorf("some tracks failed to download") + } + return nil +} + +// handleDeezerAlbumImproved handles downloading a Deezer album with improved UI +func handleDeezerAlbumImproved(id string, downloadPath string, quality int) error { + display.PrintHeader("Deezer Album Download") + + display.PrintSearching("Deezer for album info") + album, err := api.GetAlbumInfo(id) + if err != nil { + display.PrintSearchResult(false) + return fmt.Errorf("failed to get album info from Deezer: %v", err) + } + display.PrintSearchResult(true) + + // Get album tracks + albumTracks, err := api.GetAlbumTracks(id) + if err != nil { + display.PrintError("Failed to get album tracks from Deezer: %v", err) + return fmt.Errorf("failed to get album tracks from Deezer: %v", err) + } + + display.PrintAlbumInfo(album.ALB_TITLE, album.ART_NAME, len(albumTracks.Data), quality) + + // Create a folder for the album + albumPath := filepath.Join(downloadPath, album.ALB_TITLE) + + // Download the tracks + total := len(albumTracks.Data) + succeeded := 0 + failed := 0 + + display.PrintInfo("Starting download of %d tracks...", total) + fmt.Println() + + for i, track := range albumTracks.Data { + trackInfo, err := api.GetTrackInfo(fmt.Sprint(track.SNG_ID)) + if err != nil { + display.PrintError("[%d/%d] Failed to get info for: %s", i+1, total, track.SNG_TITLE) + failed++ + continue + } + + // Custom filename for the track: Artist - Title + customFilename := fmt.Sprintf("%s - %s", trackInfo.ART_NAME, trackInfo.SNG_TITLE) + + display.PrintInfo("[%d/%d] Downloading: %s", i+1, total, customFilename) + err = downloadTrackImproved(trackInfo, albumPath, quality, customFilename) + if err != nil { + display.PrintError("Failed: %v", err) + failed++ + } else { + succeeded++ + } + } + + display.PrintDownloadSummary(succeeded, failed, total) + + if failed > 0 { + return fmt.Errorf("some tracks failed to download") + } + return nil +} + +// handleSpotifyPlaylistImproved handles downloading a Spotify playlist with improved UI +func handleSpotifyPlaylistImproved(ctx context.Context, spotifyService *spotify.SpotifyService, id string, downloadPath string, quality int) error { + display.PrintHeader("Spotify Playlist Download") + + display.PrintSearching("Spotify for playlist info") + playlist, tracks, err := spotifyService.FetchPlaylist(ctx, id) + if err != nil { + display.PrintSearchResult(false) + return fmt.Errorf("failed to fetch playlist from Spotify: %v", err) + } + display.PrintSearchResult(true) + + display.PrintPlaylistInfo(playlist.Title, playlist.OwnerName, len(tracks), quality) + + // Create a folder for the playlist using just the playlist name + playlistPath := filepath.Join(downloadPath, playlist.Title) + + return downloadSpotifyTracksIndividuallyImproved(tracks, playlistPath, quality, "") +} + +// handleDeezerPlaylistImproved handles downloading a Deezer playlist with improved UI +func handleDeezerPlaylistImproved(id string, downloadPath string, quality int) error { + display.PrintHeader("Deezer Playlist Download") + + display.PrintSearching("Deezer for playlist info") + playlist, err := api.GetPlaylistInfo(id) + if err != nil { + display.PrintSearchResult(false) + return fmt.Errorf("failed to get playlist info from Deezer: %v", err) + } + display.PrintSearchResult(true) + + // Get playlist tracks + tracks, err := api.GetPlaylistTracks(id) + if err != nil { + display.PrintError("Failed to get playlist tracks from Deezer: %v", err) + return fmt.Errorf("failed to get playlist tracks from Deezer: %v", err) + } + + display.PrintPlaylistInfo(playlist.Title, "", len(tracks.Data), quality) + + // Create a folder for the playlist using just the playlist name + playlistPath := filepath.Join(downloadPath, playlist.Title) + + // Download the tracks + total := len(tracks.Data) + succeeded := 0 + failed := 0 + + display.PrintInfo("Starting download of %d tracks...", total) + fmt.Println() + + for i, track := range tracks.Data { + trackInfo, err := api.GetTrackInfo(fmt.Sprint(track.SNG_ID)) + if err != nil { + display.PrintError("[%d/%d] Failed to get info for: %s by %s", i+1, total, track.SNG_TITLE, track.ART_NAME) + failed++ + continue + } + + // Custom filename for the track: Artist - Title + customFilename := fmt.Sprintf("%s - %s", trackInfo.ART_NAME, trackInfo.SNG_TITLE) + + display.PrintInfo("[%d/%d] Downloading: %s", i+1, total, customFilename) + err = downloadTrackImproved(trackInfo, playlistPath, quality, customFilename) + if err != nil { + display.PrintError("Failed: %v", err) + failed++ + } else { + succeeded++ + } + } + + display.PrintDownloadSummary(succeeded, failed, total) + + if failed > 0 { + return fmt.Errorf("%d out of %d tracks failed to download", failed, total) + } + return nil +} + +// downloadSpotifyTracksIndividuallyImproved searches for and downloads each track individually with improved UI +func downloadSpotifyTracksIndividuallyImproved(tracks []models.Track, downloadPath string, quality int, playlistName string) error { + total := len(tracks) + succeeded := 0 + failed := 0 + + display.PrintInfo("Matching %d tracks from Spotify to Deezer...", total) + fmt.Println() + + for i, track := range tracks { + // Add a small delay to avoid overwhelming the Deezer API + if i > 0 && i%3 == 0 { + time.Sleep(1 * time.Second) + } + + trackName := fmt.Sprintf("%s by %s", track.Title, joinArtistNames(track.Artists)) + display.PrintInfo("[%d/%d] Searching for: %s", i+1, total, trackName) + + deezerTrack, err := api.SearchTrackOnDeezer(&track) + if err != nil { + display.PrintError("Not found on Deezer: %v", err) + failed++ + continue + } + + // Always use "Artist - Title" format for all tracks + customFilename := fmt.Sprintf("%s - %s", deezerTrack.ART_NAME, deezerTrack.SNG_TITLE) + + // Download the track + err = downloadTrackImproved(deezerTrack, downloadPath, quality, customFilename) + if err != nil { + display.PrintError("Download failed: %v", err) + failed++ + continue + } + + succeeded++ + } + + display.PrintDownloadSummary(succeeded, failed, total) + + if failed > 0 { + return fmt.Errorf("%d out of %d tracks failed to download", failed, total) + } + return nil +} + +// downloadTrackImproved downloads a single track from Deezer with improved UI +func downloadTrackImproved(track types.TrackType, downloadPath string, quality int, customFilename string) error { + // Determine cover size based on quality + coverSize := 500 + if quality == 9 { + coverSize = 1000 + } + + // Create the directory if it doesn't exist + if err := os.MkdirAll(downloadPath, 0755); err != nil { + return fmt.Errorf("failed to create download directory: %v", err) + } + + // Check if file already exists + ext := "mp3" + if quality == 9 { + ext = "flac" + } + fullPath := filepath.Join(downloadPath, fmt.Sprintf("%s.%s", utils.SanitizeFileName(customFilename), ext)) + + if _, err := os.Stat(fullPath); err == nil { + display.PrintFileExists(filepath.Base(fullPath)) + return nil + } + + // Create a custom progress callback + var progressBar *ui.SimpleProgress + progressCallback := func(progress float64, downloaded, total int64) { + if progressBar == nil && total > 0 { + progressBar = display.StartProgress(track.SNG_ID, total, customFilename) + } + if progressBar != nil && total > 0 { + progressBar.Update(downloaded) + } + } + + // Create download options + options := download.DownloadTrackOptions{ + SngID: fmt.Sprint(track.SNG_ID), + Quality: quality, + CoverSize: coverSize, + SaveToDir: downloadPath, + Filename: utils.SanitizeFileName(customFilename), + OnProgress: progressCallback, + } + + // Execute download + _, err := download.DownloadTrack(options) + if err != nil { + if progressBar != nil { + progressBar.Clear() + } + return err + } + + if progressBar != nil { + display.FinishProgress(track.SNG_ID) + } + + return nil +} \ No newline at end of file diff --git a/cmd/gofi/cmd/root.go b/cmd/gofi/cmd/root.go new file mode 100644 index 0000000..1bc96c6 --- /dev/null +++ b/cmd/gofi/cmd/root.go @@ -0,0 +1,153 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/d-fi/GoFi/logger" + "github.com/rs/zerolog" + "github.com/spf13/cobra" +) + +var ( + downloadPath string + quality int + logLevel string + version = "dev" // Set by build flags +) + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: "gofi", + Short: "GoFi is a music download tool for Deezer with Spotify integration", + Long: `GoFi is a music download tool written in Go that allows you to search +and download music from Deezer. It now supports Spotify integration for finding +tracks on Deezer using Spotify URLs. + +You can download tracks, albums, and playlists in different qualities.`, + Version: version, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + // Set up logging + level, err := zerolog.ParseLevel(logLevel) + if err != nil { + fmt.Printf("Warning: Invalid log level '%s', defaulting to 'info'\n", logLevel) + level = zerolog.InfoLevel + } + logger.SetLogLevel(level) + + // Ensure download path exists + if downloadPath != "" { + err := os.MkdirAll(downloadPath, 0755) + if err != nil { + fmt.Printf("Error creating download directory: %v\n", err) + os.Exit(1) + } + + // Convert to absolute path + absPath, err := filepath.Abs(downloadPath) + if err != nil { + fmt.Printf("Error resolving download path: %v\n", err) + os.Exit(1) + } + downloadPath = absPath + } + }, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +func init() { + // Load environment variables from .env file if it exists + loadEnvFile() + + // Check for GOFI_* environment variables and set defaults + defaultOutput := getEnvOrDefault("GOFI_OUTPUT_DIR", "./downloads") + defaultQuality := getEnvIntOrDefault("GOFI_QUALITY", 3) + defaultLogLevel := getEnvOrDefault("GOFI_LOG_LEVEL", "info") + + // Validate quality value from environment + if defaultQuality != 1 && defaultQuality != 3 && defaultQuality != 9 { + fmt.Printf("Warning: Invalid GOFI_QUALITY value '%d'. Must be 1, 3, or 9. Using default: 3\n", defaultQuality) + defaultQuality = 3 + } + + // Persistent flags that are global across all commands + rootCmd.PersistentFlags().StringVarP(&downloadPath, "output", "o", defaultOutput, "Directory to save downloaded files (env: GOFI_OUTPUT_DIR)") + rootCmd.PersistentFlags().IntVarP(&quality, "quality", "q", defaultQuality, "Audio quality - 1=128kbps MP3, 3=320kbps MP3, 9=FLAC (env: GOFI_QUALITY)") + rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", defaultLogLevel, "Log level - debug, info, warn, error (env: GOFI_LOG_LEVEL)") + + // Add subcommands + rootCmd.AddCommand(authCmd) + rootCmd.AddCommand(downloadCmd) +} + +// loadEnvFile loads environment variables from .env file +func loadEnvFile() { + // Try to open .env file + data, err := os.ReadFile(".env") + if err != nil { + // .env file doesn't exist or can't be read, which is fine + logger.Debug("No .env file found or unable to read it: %v", err) + return + } + + logger.Debug("Loading environment variables from .env file") + + // Parse and set environment variables + lines := strings.Split(string(data), "\n") + for _, line := range lines { + // Skip empty lines and comments + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Split key and value + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + // Skip lines that don't have a key=value format + continue + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + // Remove quotes if present + if len(value) > 1 && (value[0] == '"' || value[0] == '\'') && value[0] == value[len(value)-1] { + value = value[1 : len(value)-1] + } + + // Set environment variable + os.Setenv(key, value) + logger.Debug("Set environment variable: %s", key) + } +} + +// getEnvOrDefault returns the value of an environment variable or a default value if not set +func getEnvOrDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// getEnvIntOrDefault returns the integer value of an environment variable or a default value if not set or invalid +func getEnvIntOrDefault(key string, defaultValue int) int { + if value := os.Getenv(key); value != "" { + if intValue, err := strconv.Atoi(value); err == nil { + return intValue + } + fmt.Printf("Warning: Invalid integer value '%s' for %s\n", value, key) + } + return defaultValue +} \ No newline at end of file diff --git a/cmd/gofi/cmd/root_test.go b/cmd/gofi/cmd/root_test.go new file mode 100644 index 0000000..e8e1a3d --- /dev/null +++ b/cmd/gofi/cmd/root_test.go @@ -0,0 +1,233 @@ +package cmd + +import ( + "os" + "testing" +) + +func TestGetEnvOrDefault(t *testing.T) { + tests := []struct { + name string + envKey string + envValue string + defaultValue string + expected string + }{ + { + name: "Environment variable set", + envKey: "TEST_ENV_VAR", + envValue: "test_value", + defaultValue: "default", + expected: "test_value", + }, + { + name: "Environment variable not set", + envKey: "TEST_ENV_VAR_NOT_SET", + envValue: "", + defaultValue: "default", + expected: "default", + }, + { + name: "Empty environment variable", + envKey: "TEST_ENV_EMPTY", + envValue: "", + defaultValue: "default", + expected: "default", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set or unset environment variable + if tt.envValue != "" { + os.Setenv(tt.envKey, tt.envValue) + defer os.Unsetenv(tt.envKey) + } else { + os.Unsetenv(tt.envKey) + } + + // Test the function + result := getEnvOrDefault(tt.envKey, tt.defaultValue) + if result != tt.expected { + t.Errorf("getEnvOrDefault() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestGetEnvIntOrDefault(t *testing.T) { + tests := []struct { + name string + envKey string + envValue string + defaultValue int + expected int + expectWarn bool + }{ + { + name: "Valid integer", + envKey: "TEST_INT_VAR", + envValue: "42", + defaultValue: 10, + expected: 42, + expectWarn: false, + }, + { + name: "Invalid integer", + envKey: "TEST_INT_INVALID", + envValue: "not_a_number", + defaultValue: 10, + expected: 10, + expectWarn: true, + }, + { + name: "Environment variable not set", + envKey: "TEST_INT_NOT_SET", + envValue: "", + defaultValue: 10, + expected: 10, + expectWarn: false, + }, + { + name: "Zero value", + envKey: "TEST_INT_ZERO", + envValue: "0", + defaultValue: 10, + expected: 0, + expectWarn: false, + }, + { + name: "Negative value", + envKey: "TEST_INT_NEGATIVE", + envValue: "-5", + defaultValue: 10, + expected: -5, + expectWarn: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set or unset environment variable + if tt.envValue != "" { + os.Setenv(tt.envKey, tt.envValue) + defer os.Unsetenv(tt.envKey) + } else { + os.Unsetenv(tt.envKey) + } + + // Test the function + result := getEnvIntOrDefault(tt.envKey, tt.defaultValue) + if result != tt.expected { + t.Errorf("getEnvIntOrDefault() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestEnvironmentVariableIntegration(t *testing.T) { + // Save original values + origOutput := os.Getenv("GOFI_OUTPUT_DIR") + origQuality := os.Getenv("GOFI_QUALITY") + origLogLevel := os.Getenv("GOFI_LOG_LEVEL") + + // Restore original values after test + defer func() { + if origOutput != "" { + os.Setenv("GOFI_OUTPUT_DIR", origOutput) + } else { + os.Unsetenv("GOFI_OUTPUT_DIR") + } + if origQuality != "" { + os.Setenv("GOFI_QUALITY", origQuality) + } else { + os.Unsetenv("GOFI_QUALITY") + } + if origLogLevel != "" { + os.Setenv("GOFI_LOG_LEVEL", origLogLevel) + } else { + os.Unsetenv("GOFI_LOG_LEVEL") + } + }() + + tests := []struct { + name string + outputDir string + quality string + logLevel string + expectedOutput string + expectedQuality int + expectedLog string + }{ + { + name: "All environment variables set", + outputDir: "/tmp/test-music", + quality: "9", + logLevel: "debug", + expectedOutput: "/tmp/test-music", + expectedQuality: 9, + expectedLog: "debug", + }, + { + name: "Invalid quality - should use default", + outputDir: "/tmp/test", + quality: "7", + logLevel: "info", + expectedOutput: "/tmp/test", + expectedQuality: 3, + expectedLog: "info", + }, + { + name: "No environment variables - use defaults", + outputDir: "", + quality: "", + logLevel: "", + expectedOutput: "./downloads", + expectedQuality: 3, + expectedLog: "info", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set environment variables + if tt.outputDir != "" { + os.Setenv("GOFI_OUTPUT_DIR", tt.outputDir) + } else { + os.Unsetenv("GOFI_OUTPUT_DIR") + } + if tt.quality != "" { + os.Setenv("GOFI_QUALITY", tt.quality) + } else { + os.Unsetenv("GOFI_QUALITY") + } + if tt.logLevel != "" { + os.Setenv("GOFI_LOG_LEVEL", tt.logLevel) + } else { + os.Unsetenv("GOFI_LOG_LEVEL") + } + + // Test getEnvOrDefault for output dir + output := getEnvOrDefault("GOFI_OUTPUT_DIR", "./downloads") + if output != tt.expectedOutput { + t.Errorf("Expected output dir %s, got %s", tt.expectedOutput, output) + } + + // Test getEnvIntOrDefault for quality + quality := getEnvIntOrDefault("GOFI_QUALITY", 3) + // Apply validation as in init() + if quality != 1 && quality != 3 && quality != 9 { + quality = 3 + } + if quality != tt.expectedQuality { + t.Errorf("Expected quality %d, got %d", tt.expectedQuality, quality) + } + + // Test getEnvOrDefault for log level + logLevel := getEnvOrDefault("GOFI_LOG_LEVEL", "info") + if logLevel != tt.expectedLog { + t.Errorf("Expected log level %s, got %s", tt.expectedLog, logLevel) + } + }) + } +} \ No newline at end of file diff --git a/cmd/gofi/cmd/utils.go b/cmd/gofi/cmd/utils.go new file mode 100644 index 0000000..9176961 --- /dev/null +++ b/cmd/gofi/cmd/utils.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/d-fi/GoFi/internal/services/spotify" + "github.com/d-fi/GoFi/logger" + spotifyClient "github.com/zmb3/spotify/v2" +) + +// Helper function to get authenticated client (handles prompting for auth) +// Returns nil client if auth fails or is not configured. +func getAuthenticatedSpotifyClient(ctx context.Context) (*spotifyClient.Client, *spotify.AuthService) { + clientID := os.Getenv("SPOTIFY_CLIENT_ID") + clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET") + if clientID == "" || clientSecret == "" { + logger.Info("SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET environment variables not set. Spotify features disabled.") + return nil, nil + } + cfg := spotify.Config{ClientID: clientID, ClientSecret: clientSecret} + authService, err := spotify.NewAuthService(cfg) + if err != nil { + logger.Error("Error creating Spotify auth service: %v", err) + return nil, nil + } + + client, err := authService.GetClient(ctx) // Attempts to load token or refresh + if err != nil { + // This is not necessarily an error, just means we need to authenticate. + // Logged as Info level. The calling function decides if it's fatal. + logger.Info("Could not automatically retrieve Spotify token (may need initial auth): %v", err) + fmt.Println("Could not retrieve Spotify token. Please run 'gofi auth spotify' first.") + return nil, authService // Return authService even if client is nil + } + logger.Debug("Successfully obtained authenticated Spotify client.") + return client, authService +} \ No newline at end of file diff --git a/cmd/gofi/main.go b/cmd/gofi/main.go new file mode 100644 index 0000000..8897f45 --- /dev/null +++ b/cmd/gofi/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/d-fi/GoFi/cmd/gofi/cmd" + +func main() { + cmd.Execute() +} \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go index b000af4..7997dcf 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,7 +1,607 @@ package main -import "fmt" +import ( + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + + "github.com/d-fi/GoFi/api" + "github.com/d-fi/GoFi/download" + "github.com/d-fi/GoFi/logger" + "github.com/d-fi/GoFi/request" + "github.com/d-fi/GoFi/types" + "github.com/d-fi/GoFi/utils" +) + +var ( + appConfig types.Config +) func main() { - fmt.Println("To-Do: Implement music download functionality") + // Set up command line flags + var ( + arl string + configFile string + logLevel string + ) + + flag.StringVar(&arl, "arl", "", "Deezer ARL token (required for authentication)") + flag.StringVar(&configFile, "config", "", "Path to config file (if not using command line args)") + flag.StringVar(&logLevel, "log-level", "info", "Log level (debug, info, warn, error)") + flag.Parse() + + // Set log level based on flag (debug environment variable is used in logger init) + if logLevel == "debug" { + os.Setenv("DEBUG", "true") + } + + // Load configuration from file if specified + appConfig = types.DefaultConfig() + if configFile != "" { + loadedConfig, err := loadConfigFromFile(configFile) + if err != nil { + fmt.Printf("Error loading config file: %v\n", err) + os.Exit(1) + } + appConfig = loadedConfig + logger.Debug("Loaded configuration from file: %s", configFile) + } + + // Check for ARL in environment variable or config file if not provided via flag + if arl == "" { + arl = os.Getenv("DEEZER_ARL") + if arl == "" && appConfig.Cookies.ARL != "" { + arl = appConfig.Cookies.ARL + logger.Debug("Using ARL from config file") + } + } + + // Validate ARL + if arl == "" { + fmt.Println("Error: Deezer ARL token is required.") + fmt.Println("You can provide it using the -arl flag, set the DEEZER_ARL environment variable, or specify in config file.") + fmt.Println("\nUsage: ./d-fi -arl=YOUR_ARL_TOKEN") + os.Exit(1) + } + + // Initialize Deezer API + sessionID, err := request.InitDeezerAPI(arl) + if err != nil { + fmt.Printf("Failed to initialize Deezer API: %v\n", err) + os.Exit(1) + } + + logger.Debug("Successfully authenticated with Deezer (Session ID: %s)", sessionID) + + // Check for any arguments after flags + args := flag.Args() + if len(args) == 0 { + printUsage() + return + } + + // Process the command + command := args[0] + switch command { + case "search": + if len(args) < 2 { + fmt.Println("Error: Search query required") + return + } + query := strings.Join(args[1:], " ") + searchMusic(query) + case "download": + if len(args) < 3 { + fmt.Println("Error: Usage: download ") + return + } + downloadType := args[1] + nameOrID := strings.Join(args[2:], " ") + + switch downloadType { + case "track": + downloadTrackByNameOrID(nameOrID) + case "album": + downloadAlbumByNameOrID(nameOrID) + case "playlist": + downloadPlaylistByNameOrID(nameOrID) + default: + fmt.Printf("Error: Unknown download type: %s\n", downloadType) + fmt.Println("Available types: track, album, playlist") + } + default: + fmt.Printf("Unknown command: %s\n", command) + printUsage() + } +} + +func loadConfigFromFile(configPath string) (types.Config, error) { + config := types.DefaultConfig() + + // Read the config file + data, err := os.ReadFile(configPath) + if err != nil { + return config, fmt.Errorf("failed to read config file: %v", err) + } + + // Parse the JSON + if err := json.Unmarshal(data, &config); err != nil { + return config, fmt.Errorf("failed to parse config file: %v", err) + } + + return config, nil +} + +func printUsage() { + fmt.Println("GoFi - Deezer music downloader") + fmt.Println("\nUsage:") + fmt.Println(" ./d-fi [options] command [arguments]") + fmt.Println("\nOptions:") + fmt.Println(" -arl string Deezer ARL token (required for authentication)") + fmt.Println(" -config string Path to config file") + fmt.Println(" -log-level string Log level (debug, info, warn, error) (default \"info\")") + fmt.Println("\nCommands:") + fmt.Println(" search Search for tracks, albums, or artists") + fmt.Println(" download Download track, album, or playlist by name or ID") + fmt.Println(" Types: track, album, playlist") + fmt.Println("\nExamples:") + fmt.Println(" ./d-fi -arl=YOUR_ARL search \"daft punk\"") + fmt.Println(" ./d-fi -arl=YOUR_ARL download track \"Harder Better Faster Stronger\"") + fmt.Println(" ./d-fi -arl=YOUR_ARL download track 3135556") + fmt.Println(" ./d-fi -config=config.json download album \"Discovery\"") + fmt.Println(" ./d-fi -config=config.json download album 302127") +} + +func searchMusic(query string) { + fmt.Printf("Searching for: %s\n", query) + + results, err := api.SearchMusic(query, 10, "TRACK", "ALBUM", "ARTIST") + if err != nil { + fmt.Printf("Search failed: %v\n", err) + return + } + + // Display track results + if len(results.TRACK.Data) > 0 { + fmt.Println("\nTracks:") + for i, track := range results.TRACK.Data { + fmt.Printf("%d. %s - %s (ID: %s)\n", i+1, track.ART_NAME, track.SNG_TITLE, track.SNG_ID) + } + } + + // Display album results + if len(results.ALBUM.Data) > 0 { + fmt.Println("\nAlbums:") + for i, album := range results.ALBUM.Data { + fmt.Printf("%d. %s - %s (ID: %s)\n", i+1, album.ART_NAME, album.ALB_TITLE, album.ALB_ID) + } + } + + // Display artist results + if len(results.ARTIST.Data) > 0 { + fmt.Println("\nArtists:") + for i, artist := range results.ARTIST.Data { + fmt.Printf("%d. %s (ID: %s)\n", i+1, artist.ART_NAME, artist.ART_ID) + } + } +} + +func downloadTrackByNameOrID(nameOrID string) { + // Check if the input is an ID (all digits) + isID := true + for _, char := range nameOrID { + if char < '0' || char > '9' { + isID = false + break + } + } + + var trackID string + if isID { + trackID = nameOrID + fmt.Printf("Downloading track with ID: %s\n", trackID) + } else { + // Search for the track by name + fmt.Printf("Searching for track: %s\n", nameOrID) + results, err := api.SearchMusic(nameOrID, 5, "TRACK") + if err != nil { + fmt.Printf("Error: Failed to search for track: %v\n", err) + return + } + + if len(results.TRACK.Data) == 0 { + fmt.Println("Error: No tracks found with that name") + return + } + + // Display the found tracks and ask the user to select one + fmt.Println("Found tracks:") + for i, track := range results.TRACK.Data { + fmt.Printf("%d. %s - %s (ID: %s)\n", i+1, track.ART_NAME, track.SNG_TITLE, track.SNG_ID) + } + + if len(results.TRACK.Data) > 1 { + fmt.Print("\nSelect a track (1-5) or press Enter for the first result: ") + var choice string + fmt.Scanln(&choice) + + if choice == "" { + trackID = results.TRACK.Data[0].SNG_ID + } else { + index, err := strconv.Atoi(choice) + if err != nil || index < 1 || index > len(results.TRACK.Data) { + fmt.Println("Invalid selection, using the first result") + trackID = results.TRACK.Data[0].SNG_ID + } else { + trackID = results.TRACK.Data[index-1].SNG_ID + } + } + } else { + trackID = results.TRACK.Data[0].SNG_ID + } + } + + downloadTrack(trackID) +} + +func downloadAlbumByNameOrID(nameOrID string) { + // Check if the input is an ID (all digits) + isID := true + for _, char := range nameOrID { + if char < '0' || char > '9' { + isID = false + break + } + } + + var albumID string + if isID { + albumID = nameOrID + fmt.Printf("Downloading album with ID: %s\n", albumID) + } else { + // Search for the album by name + fmt.Printf("Searching for album: %s\n", nameOrID) + results, err := api.SearchMusic(nameOrID, 5, "ALBUM") + if err != nil { + fmt.Printf("Error: Failed to search for album: %v\n", err) + return + } + + if len(results.ALBUM.Data) == 0 { + fmt.Println("Error: No albums found with that name") + return + } + + // Display the found albums and ask the user to select one + fmt.Println("Found albums:") + for i, album := range results.ALBUM.Data { + fmt.Printf("%d. %s - %s (ID: %s)\n", i+1, album.ART_NAME, album.ALB_TITLE, album.ALB_ID) + } + + if len(results.ALBUM.Data) > 1 { + fmt.Print("\nSelect an album (1-5) or press Enter for the first result: ") + var choice string + fmt.Scanln(&choice) + + if choice == "" { + albumID = results.ALBUM.Data[0].ALB_ID + } else { + index, err := strconv.Atoi(choice) + if err != nil || index < 1 || index > len(results.ALBUM.Data) { + fmt.Println("Invalid selection, using the first result") + albumID = results.ALBUM.Data[0].ALB_ID + } else { + albumID = results.ALBUM.Data[index-1].ALB_ID + } + } + } else { + albumID = results.ALBUM.Data[0].ALB_ID + } + } + + downloadAlbum(albumID) +} + +func downloadPlaylistByNameOrID(nameOrID string) { + // Check if the input is an ID (all digits) + isID := true + for _, char := range nameOrID { + if char < '0' || char > '9' { + isID = false + break + } + } + + var playlistID string + if isID { + playlistID = nameOrID + fmt.Printf("Downloading playlist with ID: %s\n", playlistID) + } else { + // Search for the playlist by name + fmt.Printf("Searching for playlist: %s\n", nameOrID) + results, err := api.SearchMusic(nameOrID, 5, "PLAYLIST") + if err != nil { + fmt.Printf("Error: Failed to search for playlist: %v\n", err) + return + } + + if len(results.PLAYLIST.Data) == 0 { + fmt.Println("Error: No playlists found with that name") + return + } + + // Display the found playlists and ask the user to select one + fmt.Println("Found playlists:") + for i, playlist := range results.PLAYLIST.Data { + fmt.Printf("%d. %s (ID: %s)\n", i+1, playlist.Title, playlist.PlaylistID) + } + + if len(results.PLAYLIST.Data) > 1 { + fmt.Print("\nSelect a playlist (1-5) or press Enter for the first result: ") + var choice string + fmt.Scanln(&choice) + + if choice == "" { + playlistID = results.PLAYLIST.Data[0].PlaylistID + } else { + index, err := strconv.Atoi(choice) + if err != nil || index < 1 || index > len(results.PLAYLIST.Data) { + fmt.Println("Invalid selection, using the first result") + playlistID = results.PLAYLIST.Data[0].PlaylistID + } else { + playlistID = results.PLAYLIST.Data[index-1].PlaylistID + } + } + } else { + playlistID = results.PLAYLIST.Data[0].PlaylistID + } + } + + downloadPlaylist(playlistID) +} + +func downloadTrack(trackID string) { + fmt.Println("Downloading track...") + + // Get the track info + track, err := api.GetTrackInfo(trackID) + if err != nil { + fmt.Printf("Error: Failed to get track info: %v\n", err) + return + } + + // Create the download directory based on the config + downloadPath := expandPathTemplate(appConfig.SaveLayout.Track, track, nil, "") + dir := filepath.Dir(downloadPath) + + // Extract just the filename part without extension + filename := filepath.Base(downloadPath) + + // Create the directory if it doesn't exist + if err := os.MkdirAll(dir, 0755); err != nil { + fmt.Printf("Error: Failed to create download directory: %v\n", err) + return + } + + // Determine quality based on track availability + quality := 9 // FLAC by default + + // Progress tracking function + progressFunc := func(progress float64, downloaded, total int64) { + fmt.Printf("\rDownloading: %.1f%% (%s/%s)", progress, formatSize(downloaded), formatSize(total)) + } + + // Download the track + options := download.DownloadTrackOptions{ + SngID: trackID, + Quality: quality, + CoverSize: appConfig.CoverSize.Flac, + SaveToDir: dir, + Filename: filename, + OnProgress: progressFunc, + } + + filePath, err := download.DownloadTrack(options) + if err != nil { + fmt.Printf("\nError: Failed to download track: %v\n", err) + return + } + + fmt.Printf("\nTrack downloaded successfully: %s\n", filePath) +} + +func downloadAlbum(albumID string) { + fmt.Println("Downloading album...") + + // Get the album info + album, err := api.GetAlbumInfo(albumID) + if err != nil { + fmt.Printf("Error: Failed to get album info: %v\n", err) + return + } + + // Get the tracks in the album + tracks, err := api.GetAlbumTracks(albumID) + if err != nil { + fmt.Printf("Error: Failed to get album tracks: %v\n", err) + return + } + + fmt.Printf("Album: %s by %s\n", album.ALB_TITLE, album.ART_NAME) + fmt.Printf("Total tracks: %d\n", len(tracks.Data)) + + // Create a wait group to track download completion + var wg sync.WaitGroup + concurrency := appConfig.Concurrency + semaphore := make(chan struct{}, concurrency) + + for i, track := range tracks.Data { + wg.Add(1) + semaphore <- struct{}{} // Acquire semaphore + + go func(i int, track types.TrackType) { + defer wg.Done() + defer func() { <-semaphore }() // Release semaphore + + fmt.Printf("[%d/%d] Downloading: %s - %s\n", i+1, len(tracks.Data), track.ART_NAME, track.SNG_TITLE) + + // Create the download directory based on the config + downloadPath := expandPathTemplate(appConfig.SaveLayout.Album, track, &album, "") + dir := filepath.Dir(downloadPath) + + // Extract just the filename part without extension + filename := filepath.Base(downloadPath) + + // Create the directory if it doesn't exist + if err := os.MkdirAll(dir, 0755); err != nil { + fmt.Printf("Error: Failed to create download directory for track %s: %v\n", track.SNG_TITLE, err) + return + } + + // Determine quality based on track availability + quality := 9 // FLAC by default + + // Download the track + options := download.DownloadTrackOptions{ + SngID: track.SNG_ID, + Quality: quality, + CoverSize: appConfig.CoverSize.Flac, + SaveToDir: dir, + Filename: filename, + } + + filePath, err := download.DownloadTrack(options) + if err != nil { + fmt.Printf("Error: Failed to download track %s: %v\n", track.SNG_TITLE, err) + return + } + + fmt.Printf("Track downloaded: %s\n", filepath.Base(filePath)) + }(i, track) + } + + wg.Wait() + fmt.Println("Album download completed!") +} + +func downloadPlaylist(playlistID string) { + fmt.Println("Downloading playlist...") + + // Get the playlist info + playlist, err := api.GetPlaylistInfo(playlistID) + if err != nil { + fmt.Printf("Error: Failed to get playlist info: %v\n", err) + return + } + + // Get the tracks in the playlist + tracks, err := api.GetPlaylistTracks(playlistID) + if err != nil { + fmt.Printf("Error: Failed to get playlist tracks: %v\n", err) + return + } + + fmt.Printf("Playlist: %s\n", playlist.Title) + fmt.Printf("Total tracks: %d\n", len(tracks.Data)) + + // Create a wait group to track download completion + var wg sync.WaitGroup + concurrency := appConfig.Concurrency + semaphore := make(chan struct{}, concurrency) + + for i, track := range tracks.Data { + wg.Add(1) + semaphore <- struct{}{} // Acquire semaphore + + go func(i int, track types.TrackType) { + defer wg.Done() + defer func() { <-semaphore }() // Release semaphore + + fmt.Printf("[%d/%d] Downloading: %s - %s\n", i+1, len(tracks.Data), track.ART_NAME, track.SNG_TITLE) + + // Create the download directory based on the config + downloadPath := expandPathTemplate(appConfig.SaveLayout.Playlist, track, nil, playlist.Title) + dir := filepath.Dir(downloadPath) + + // Extract just the filename part without extension + filename := filepath.Base(downloadPath) + + // Create the directory if it doesn't exist + if err := os.MkdirAll(dir, 0755); err != nil { + fmt.Printf("Error: Failed to create download directory for track %s: %v\n", track.SNG_TITLE, err) + return + } + + // Determine quality based on track availability + quality := 9 // FLAC by default + + // Download the track + options := download.DownloadTrackOptions{ + SngID: track.SNG_ID, + Quality: quality, + CoverSize: appConfig.CoverSize.Flac, + SaveToDir: dir, + Filename: filename, + } + + filePath, err := download.DownloadTrack(options) + if err != nil { + fmt.Printf("Error: Failed to download track %s: %v\n", track.SNG_TITLE, err) + return + } + + fmt.Printf("Track downloaded: %s\n", filepath.Base(filePath)) + }(i, track) + } + + wg.Wait() + fmt.Println("Playlist download completed!") +} + +// expandPathTemplate replaces placeholders in the template with actual values +func expandPathTemplate(template string, track types.TrackType, album *types.AlbumType, playlistTitle string) string { + result := template + + // Track info + result = strings.ReplaceAll(result, "{SNG_TITLE}", utils.SanitizeFileName(track.SNG_TITLE)) + result = strings.ReplaceAll(result, "{ART_NAME}", utils.SanitizeFileName(track.ART_NAME)) + result = strings.ReplaceAll(result, "{SNG_ID}", track.SNG_ID) + + // Track number (if available) + trackNumberStr := "" + if trackNum := int(track.TRACK_NUMBER); trackNum > 0 { + trackNumberStr = fmt.Sprintf("%02d", trackNum) + } + result = strings.ReplaceAll(result, "{TRACK_NUMBER}", trackNumberStr) + + // Album info (if available) + if album != nil { + result = strings.ReplaceAll(result, "{ALB_TITLE}", utils.SanitizeFileName(album.ALB_TITLE)) + result = strings.ReplaceAll(result, "{ALB_ID}", album.ALB_ID) + } + + // Playlist title (if available) + if playlistTitle != "" { + result = strings.ReplaceAll(result, "{TITLE}", utils.SanitizeFileName(playlistTitle)) + } + + return result +} + +// formatSize formats a file size in bytes to a human-readable string +func formatSize(size int64) string { + if size < 1024 { + return fmt.Sprintf("%d B", size) + } else if size < 1024*1024 { + return fmt.Sprintf("%.1f KB", float64(size)/1024) + } else if size < 1024*1024*1024 { + return fmt.Sprintf("%.1f MB", float64(size)/1024/1024) + } else { + return fmt.Sprintf("%.1f GB", float64(size)/1024/1024/1024) + } } diff --git a/d-fi.config.example.json b/d-fi.config.example.json new file mode 100644 index 0000000..aae7ab1 --- /dev/null +++ b/d-fi.config.example.json @@ -0,0 +1,23 @@ +{ + "concurrency": 3, + "saveLayout": { + "track": "./downloads/Tracks/{ART_NAME}/{ART_NAME} - {SNG_TITLE}", + "album": "./downloads/Albums/{ART_NAME}/{ALB_TITLE}/{TRACK_NUMBER} - {SNG_TITLE}", + "artist": "./downloads/Artists/{ART_NAME}/{SNG_TITLE}", + "playlist": "./downloads/Playlists/{TITLE}/{ART_NAME} - {SNG_TITLE}" + }, + "playlist": { + "resolveFullPath": false + }, + "trackNumber": false, + "fallbackTrack": true, + "fallbackQuality": false, + "coverSize": { + "128": 500, + "320": 500, + "flac": 1000 + }, + "cookies": { + "arl": "YOUR_ARL_TOKEN_HERE" + } +} diff --git a/docs/BROWSER_COOKIES.md b/docs/BROWSER_COOKIES.md new file mode 100644 index 0000000..f83cb68 --- /dev/null +++ b/docs/BROWSER_COOKIES.md @@ -0,0 +1,76 @@ +# Browser Cookie Authentication + +GoFi can automatically read the Deezer ARL cookie from your browser, making authentication easier. + +## Supported Browsers + +- **Chrome** (Windows, macOS, Linux) +- **Firefox** (Windows, macOS, Linux) +- **Microsoft Edge** (Windows, macOS, Linux) +- **Arc** (Windows, macOS, Linux) +- **Safari** (macOS only) + +## Usage + +To authenticate with Deezer using browser cookies: + +```bash +./gofi auth deezer +``` + +This command will: +1. Search for the Deezer ARL cookie in all supported browsers +2. Validate the cookie +3. Save it to a `.env` file for future use +4. Set it in the current environment + +## How It Works + +The browser cookie reader: +- Accesses browser cookie databases (SQLite for Chrome/Firefox) +- Handles platform-specific encryption (Chrome encrypts cookies on some platforms) +- Safely reads cookies without modifying browser data +- Falls back to other browsers if one fails + +## Security Notes + +- Cookie data is read in read-only mode +- Temporary copies of cookie databases are used to avoid conflicts +- The ARL token is stored securely in your `.env` file +- Only the Deezer ARL cookie is accessed + +## Manual Authentication + +If automatic cookie reading fails, you can still authenticate manually: + +1. Log into Deezer in your browser +2. Open Developer Tools (F12) +3. Go to Application/Storage → Cookies +4. Find the `arl` cookie for `deezer.com` +5. Set it as an environment variable: + ```bash + export DEEZER_ARL="your_arl_cookie_value" + ``` + +## Troubleshooting + +If cookie reading fails: +- Ensure you're logged into Deezer in at least one supported browser +- Check that the browser is closed (some browsers lock their cookie database while running) +- On macOS, you may need to grant terminal access to browser data +- Try different browsers if one doesn't work + +## Platform-Specific Notes + +### macOS +- Chrome/Edge cookies are encrypted using the macOS Keychain +- You may be prompted for keychain access +- Safari uses a binary cookie format (not fully supported yet) + +### Windows +- Chrome/Edge cookies are encrypted using Windows DPAPI +- Full decryption support is planned for a future update + +### Linux +- Chrome/Edge cookies use a simple encryption with a known key +- Firefox cookies are stored unencrypted \ No newline at end of file diff --git a/download/download.go b/download/download.go index a2defca..9e48d14 100644 --- a/download/download.go +++ b/download/download.go @@ -48,13 +48,20 @@ func DownloadTrack(options DownloadTrackOptions) (string, error) { } logger.Debug("Download URL retrieved: %s", trackData.TrackUrl) - // Sanitize the track title to ensure it's safe for file systems - safeTitle := utils.SanitizeFileName(track.SNG_TITLE) - savedPath := filepath.Join(options.SaveToDir, fmt.Sprintf("%s-%s.%s", safeTitle, track.SNG_ID, ext)) + var savedPath string + if options.Filename != "" { + // Use the provided filename if available + savedPath = filepath.Join(options.SaveToDir, fmt.Sprintf("%s.%s", options.Filename, ext)) + } else { + // Fall back to the default naming scheme + safeTitle := utils.SanitizeFileName(track.SNG_TITLE) + savedPath = filepath.Join(options.SaveToDir, fmt.Sprintf("%s-%s.%s", safeTitle, track.SNG_ID, ext)) + } logger.Debug("Saving track as: %s", savedPath) - // If the file exists, update its timestamp and return the path + // Check if the file exists with this name or with the ID-based name if _, err := os.Stat(savedPath); err == nil { + // File exists with the specified name if err := os.Chtimes(savedPath, time.Now(), time.Now()); err != nil { logger.Debug("Failed to update file timestamps: %v", err) return "", fmt.Errorf("failed to update file timestamps: %v", err) @@ -63,6 +70,28 @@ func DownloadTrack(options DownloadTrackOptions) (string, error) { return savedPath, nil } + // Also check if the file exists with the default ID-based naming scheme + // if we're using a custom filename, to avoid duplicates + if options.Filename != "" { + safeTitle := utils.SanitizeFileName(track.SNG_TITLE) + defaultPath := filepath.Join(options.SaveToDir, fmt.Sprintf("%s-%s.%s", safeTitle, track.SNG_ID, ext)) + if _, err := os.Stat(defaultPath); err == nil { + // Rename the file to match our new naming scheme + if err := os.Rename(defaultPath, savedPath); err != nil { + logger.Debug("Failed to rename existing file: %v", err) + // Continue anyway, as we'll just overwrite the file + } else { + // Successfully renamed, update timestamp and return + if err := os.Chtimes(savedPath, time.Now(), time.Now()); err != nil { + logger.Debug("Failed to update file timestamps: %v", err) + return "", fmt.Errorf("failed to update file timestamps: %v", err) + } + logger.Debug("File renamed and timestamp updated: %s", savedPath) + return savedPath, nil + } + } + } + // Open the destination file out, err := os.Create(savedPath) if err != nil { diff --git a/download/types.go b/download/types.go index 29e980c..8582fc6 100644 --- a/download/types.go +++ b/download/types.go @@ -24,6 +24,7 @@ type DownloadTrackOptions struct { Quality int // The quality of the track (e.g., 1 for MP3_128, 3 for MP3_320, 9 for FLAC). CoverSize int // The size of the album cover in pixels. SaveToDir string // The directory where the track will be saved. + Filename string // The filename to use for the track (without extension). OnProgress func(progress float64, downloaded, total int64) // The progress callback function. } diff --git a/download/url.go b/download/url.go index f8eed46..a721fec 100644 --- a/download/url.go +++ b/download/url.go @@ -59,14 +59,47 @@ func DzAuthenticate() (*UserData, error) { return nil, err } - results := data["results"].(map[string]interface{}) - options := results["USER"].(map[string]interface{})["OPTIONS"].(map[string]interface{}) - country := results["COUNTRY"].(string) + // Check if there's an error in the response + if errData, ok := data["error"].(map[string]interface{}); ok { + errMsg := "unknown error" + if msg, ok := errData["message"].(string); ok { + errMsg = msg + } + return nil, fmt.Errorf("Deezer API error: %s", errMsg) + } + + results, ok := data["results"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid response structure: missing results") + } + + userInfo, ok := results["USER"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid response structure: missing USER") + } + + options, ok := userInfo["OPTIONS"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid response structure: missing OPTIONS") + } + + country, ok := results["COUNTRY"].(string) + if !ok { + country = "unknown" + } + // Safe extraction of options with defaults + licenseToken, _ := options["license_token"].(string) + webLossless, _ := options["web_lossless"].(bool) + mobileLossless, _ := options["mobile_lossless"].(bool) + mobileLosslessAlt, _ := options["mobile_loseless"].(bool) // Handle typo in API + webHQ, _ := options["web_hq"].(bool) + mobileHQ, _ := options["mobile_hq"].(bool) + userData = &UserData{ - LicenseToken: options["license_token"].(string), - CanStreamLossless: options["web_lossless"].(bool) || options["mobile_loseless"].(bool), - CanStreamHQ: options["web_hq"].(bool) || options["mobile_hq"].(bool), + LicenseToken: licenseToken, + CanStreamLossless: webLossless || mobileLossless || mobileLosslessAlt, + CanStreamHQ: webHQ || mobileHQ, Country: country, } logger.Debug("Deezer authentication successful. User country: %s", userData.Country) diff --git a/download/url_test.go b/download/url_test.go index 3b03355..9d38f9b 100644 --- a/download/url_test.go +++ b/download/url_test.go @@ -1,11 +1,11 @@ package download import ( - "os" "strconv" "testing" "github.com/d-fi/GoFi/api" + "github.com/d-fi/GoFi/internal/auth" "github.com/d-fi/GoFi/request" "github.com/stretchr/testify/assert" ) @@ -14,16 +14,41 @@ const ( SNG_ID = "3135556" // Harder, Better, Faster, Stronger by Daft Punk ) +var testingEnabled bool + func init() { // Initialize the Deezer API for all tests - arl := os.Getenv("DEEZER_ARL") - _, err := request.InitDeezerAPI(arl) + // Try to get ARL from various sources (env, browser cookies, etc.) + arl, err := auth.GetARLToken() + if err != nil { + // Skip tests if no ARL is available from any source + testingEnabled = false + return + } + + // Try to initialize the API and validate the token + _, err = request.InitDeezerAPI(arl) if err != nil { - panic("Failed to initialize Deezer API: " + err.Error()) + // ARL found but invalid - skip tests + testingEnabled = false + return + } + + // Try to authenticate and check permissions + user, err := DzAuthenticate() + if err != nil || (!user.CanStreamLossless && !user.CanStreamHQ) { + // Token doesn't have proper streaming permissions + testingEnabled = false + return } + + testingEnabled = true } func TestDzAuthenticate(t *testing.T) { + if !testingEnabled { + t.Skip("Skipping test: No valid ARL token available") + } user, err := DzAuthenticate() assert.NoError(t, err) assert.NotNil(t, user) @@ -33,12 +58,18 @@ func TestDzAuthenticate(t *testing.T) { } func TestGetTrackUrlFromServer(t *testing.T) { + if !testingEnabled { + t.Skip("Skipping test: No valid ARL token available") + } trackToken := "example_track_token" _, err := GetTrackUrlFromServer(trackToken, "MP3_320") assert.Error(t, err, "Expected error due to incorrect token or unavailable track") } func TestGetTrackDownloadUrl(t *testing.T) { + if !testingEnabled { + t.Skip("Skipping test: No valid ARL token available") + } track, err := api.GetTrackInfo(SNG_ID) assert.NoError(t, err, "Failed to fetch track information") assert.NotEmpty(t, track.MD5_ORIGIN, "MD5 origin should not be empty") @@ -63,6 +94,9 @@ func TestGetTrackDownloadUrl(t *testing.T) { } func TestGetTrackDownloadUrlWithInvalidQuality(t *testing.T) { + if !testingEnabled { + t.Skip("Skipping test: No valid ARL token available") + } track, err := api.GetTrackInfo(SNG_ID) assert.NoError(t, err, "Failed to fetch track information") assert.NotEmpty(t, track.TRACK_TOKEN, "Track token should not be empty") diff --git a/go.mod b/go.mod index d90b759..273ad67 100644 --- a/go.mod +++ b/go.mod @@ -6,20 +6,31 @@ toolchain go1.23.1 require ( github.com/bogem/id3v2/v2 v2.1.4 + github.com/fatih/color v1.18.0 github.com/go-resty/resty/v2 v2.15.3 + github.com/google/uuid v1.6.0 github.com/hashicorp/golang-lru/v2 v2.0.7 + github.com/mattn/go-sqlite3 v1.14.22 github.com/rs/zerolog v1.33.0 + github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.9.0 + github.com/zmb3/spotify/v2 v2.4.3 golang.org/x/crypto v0.28.0 + golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5 golang.org/x/text v0.19.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/golang/protobuf v1.5.2 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.6 // indirect golang.org/x/net v0.30.0 // indirect - golang.org/x/sys v0.26.0 // indirect + golang.org/x/sys v0.29.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.33.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 5846e42..a24aacd 100644 --- a/go.sum +++ b/go.sum @@ -1,65 +1,462 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI= github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-resty/resty/v2 v2.15.3 h1:bqff+hcqAflpiF591hhJzNdkRsFhlB96CYfBwSFvql8= github.com/go-resty/resty/v2 v2.15.3/go.mod h1:0fHAoK7JoBy/Ch36N8VFeMsK7xQOHhvWaC3iOktwmIU= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zmb3/spotify/v2 v2.4.3 h1:4divquzK2Mzo90XVIij4K7Z98Hf+6A3qPnksqtcDIuo= +github.com/zmb3/spotify/v2 v2.4.3/go.mod h1:XOV7BrThayFYB9AAfB+L0Q0wyxBuLCARk4fI/ZXCBW8= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5 h1:Ati8dO7+U7mxpkPSxBZQEvzHVUYB/MqCklCN8ig5w/o= +golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/gofi b/gofi new file mode 100755 index 0000000..9bbf7b5 Binary files /dev/null and b/gofi differ diff --git a/internal/auth/arl_helper.go b/internal/auth/arl_helper.go new file mode 100644 index 0000000..4b17599 --- /dev/null +++ b/internal/auth/arl_helper.go @@ -0,0 +1,117 @@ +package auth + +import ( + "fmt" + "os" + "strings" +) + +// GetARLToken attempts to get the ARL token from various sources +// Priority: 1. Environment variable, 2. Browser cookies, 3. Config file +func GetARLToken() (string, error) { + // First, check environment variable + if arl := os.Getenv("DEEZER_ARL"); arl != "" { + return cleanARLToken(arl), nil + } + + // Try to get from browser cookies + arl, err := GetARLFromAnyBrowser() + if err == nil && arl != "" { + return cleanARLToken(arl), nil + } + + // Return error if no ARL found + return "", fmt.Errorf("ARL token not found in environment or browser cookies: %w", err) +} + +// cleanARLToken removes any control characters from the ARL token +func cleanARLToken(arl string) string { + // Clean the ARL token - remove any control characters + cleanARL := "" + for _, r := range arl { + if r >= 32 && r <= 126 { + cleanARL += string(r) + } + } + return strings.TrimSpace(cleanARL) +} + +// ValidateARLToken performs basic validation on an ARL token +func ValidateARLToken(arl string) error { + // Remove any whitespace + arl = strings.TrimSpace(arl) + + // Basic validation: ARL tokens are typically long strings + if len(arl) < 100 { + return fmt.Errorf("ARL token appears to be invalid (too short)") + } + + // More lenient validation - just check if it has mostly valid characters + validChars := 0 + for _, char := range arl { + if isValidARLChar(char) { + validChars++ + } + } + + // If at least 90% of characters are valid, accept it + if float64(validChars) / float64(len(arl)) < 0.9 { + return fmt.Errorf("ARL token contains too many invalid characters") + } + + return nil +} + +func isValidARLChar(r rune) bool { + // ARL tokens are base64-like strings that can contain: + // - Letters (a-z, A-Z) + // - Numbers (0-9) + // - Special characters used in base64 and URL encoding + return (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '_' || r == '-' || r == '.' || r == '~' || + r == '+' || r == '/' || r == '=' || r == '%' +} + +// SaveARLToEnv saves the ARL token to a .env file +func SaveARLToEnv(arl string) error { + envPath := ".env" + + // Clean the ARL token - remove any control characters + cleanARL := "" + for _, r := range arl { + if r >= 32 && r <= 126 { + cleanARL += string(r) + } + } + + // Read existing .env file if it exists + content := "" + if data, err := os.ReadFile(envPath); err == nil { + content = string(data) + } + + // Check if DEEZER_ARL already exists + lines := strings.Split(content, "\n") + found := false + for i, line := range lines { + if strings.HasPrefix(line, "DEEZER_ARL=") { + lines[i] = fmt.Sprintf("DEEZER_ARL=%s", cleanARL) + found = true + break + } + } + + // If not found, append it + if !found { + if len(lines) > 0 && lines[len(lines)-1] != "" { + lines = append(lines, "") + } + lines = append(lines, fmt.Sprintf("DEEZER_ARL=%s", cleanARL)) + } + + // Write back to file + newContent := strings.Join(lines, "\n") + return os.WriteFile(envPath, []byte(newContent), 0644) +} \ No newline at end of file diff --git a/internal/auth/browser_cookies.go b/internal/auth/browser_cookies.go new file mode 100644 index 0000000..2c323b9 --- /dev/null +++ b/internal/auth/browser_cookies.go @@ -0,0 +1,502 @@ +package auth + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/sha1" + "database/sql" + "encoding/base64" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + _ "github.com/mattn/go-sqlite3" + "golang.org/x/crypto/pbkdf2" +) + +// BrowserType represents different browser types +type BrowserType string + +const ( + Chrome BrowserType = "chrome" + Firefox BrowserType = "firefox" + Safari BrowserType = "safari" + Edge BrowserType = "edge" + Arc BrowserType = "arc" +) + +// CookieReader provides methods to read cookies from browsers +type CookieReader struct { + browser BrowserType + os string +} + +// NewCookieReader creates a new cookie reader for the specified browser +func NewCookieReader(browser BrowserType) *CookieReader { + return &CookieReader{ + browser: browser, + os: runtime.GOOS, + } +} + +// GetDeezerARL attempts to read the 'arl' cookie from deezer.com +func (cr *CookieReader) GetDeezerARL() (string, error) { + cookiePath, err := cr.getCookiePath() + if err != nil { + return "", fmt.Errorf("failed to get cookie path: %w", err) + } + + switch cr.browser { + case Chrome, Edge, Arc: + // Try different domain variations + for _, domain := range []string{".deezer.com", "deezer.com", "www.deezer.com"} { + arl, err := cr.getChromiumCookie(cookiePath, "arl", domain) + if err == nil && arl != "" { + return arl, nil + } + } + return "", fmt.Errorf("ARL cookie not found in %s", cr.browser) + case Firefox: + return cr.getFirefoxCookie(cookiePath, "arl", ".deezer.com") + case Safari: + return cr.getSafariCookie(cookiePath, "arl", ".deezer.com") + default: + return "", fmt.Errorf("unsupported browser: %s", cr.browser) + } +} + +// getCookiePath returns the path to the browser's cookie database +func (cr *CookieReader) getCookiePath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + + var path string + switch cr.os { + case "darwin": // macOS + switch cr.browser { + case Chrome: + path = filepath.Join(home, "Library", "Application Support", "Google", "Chrome", "Default", "Cookies") + case Firefox: + // Firefox profile path needs to be discovered + profilePath, err := cr.findFirefoxProfile(filepath.Join(home, "Library", "Application Support", "Firefox", "Profiles")) + if err != nil { + return "", err + } + path = filepath.Join(profilePath, "cookies.sqlite") + case Safari: + path = filepath.Join(home, "Library", "Cookies", "Cookies.binarycookies") + case Edge: + path = filepath.Join(home, "Library", "Application Support", "Microsoft Edge", "Default", "Cookies") + case Arc: + path = filepath.Join(home, "Library", "Application Support", "Arc", "User Data", "Default", "Cookies") + default: + return "", fmt.Errorf("browser %s not supported on macOS", cr.browser) + } + case "linux": + switch cr.browser { + case Chrome: + path = filepath.Join(home, ".config", "google-chrome", "Default", "Cookies") + case Firefox: + profilePath, err := cr.findFirefoxProfile(filepath.Join(home, ".mozilla", "firefox")) + if err != nil { + return "", err + } + path = filepath.Join(profilePath, "cookies.sqlite") + case Edge: + path = filepath.Join(home, ".config", "microsoft-edge", "Default", "Cookies") + case Arc: + path = filepath.Join(home, ".config", "arc", "Default", "Cookies") + default: + return "", fmt.Errorf("browser %s not supported on Linux", cr.browser) + } + case "windows": + localAppData := os.Getenv("LOCALAPPDATA") + appData := os.Getenv("APPDATA") + switch cr.browser { + case Chrome: + path = filepath.Join(localAppData, "Google", "Chrome", "User Data", "Default", "Network", "Cookies") + case Firefox: + profilePath, err := cr.findFirefoxProfile(filepath.Join(appData, "Mozilla", "Firefox", "Profiles")) + if err != nil { + return "", err + } + path = filepath.Join(profilePath, "cookies.sqlite") + case Edge: + path = filepath.Join(localAppData, "Microsoft", "Edge", "User Data", "Default", "Network", "Cookies") + case Arc: + path = filepath.Join(localAppData, "Arc", "User Data", "Default", "Network", "Cookies") + default: + return "", fmt.Errorf("browser %s not supported on Windows", cr.browser) + } + default: + return "", fmt.Errorf("unsupported operating system: %s", cr.os) + } + + if path == "" { + return "", fmt.Errorf("could not determine cookie path for %s on %s", cr.browser, cr.os) + } + + // Check if file exists + if _, err := os.Stat(path); os.IsNotExist(err) { + return "", fmt.Errorf("cookie file not found at %s", path) + } + + return path, nil +} + +// findFirefoxProfile finds the default Firefox profile directory +func (cr *CookieReader) findFirefoxProfile(profilesDir string) (string, error) { + entries, err := os.ReadDir(profilesDir) + if err != nil { + return "", fmt.Errorf("failed to read Firefox profiles directory: %w", err) + } + + // Look for default profile (usually contains ".default" or ".default-release") + for _, entry := range entries { + if entry.IsDir() && (strings.Contains(entry.Name(), ".default") || strings.Contains(entry.Name(), ".default-release")) { + return filepath.Join(profilesDir, entry.Name()), nil + } + } + + // If no default found, return the first profile + for _, entry := range entries { + if entry.IsDir() && !strings.HasPrefix(entry.Name(), ".") { + return filepath.Join(profilesDir, entry.Name()), nil + } + } + + return "", errors.New("no Firefox profile found") +} + +// getChromiumCookie reads a cookie from Chrome/Edge cookie database +func (cr *CookieReader) getChromiumCookie(dbPath, name, domain string) (string, error) { + // Make a copy of the database to avoid lock issues + tempDB := dbPath + ".tmp" + if err := copyFile(dbPath, tempDB); err != nil { + return "", fmt.Errorf("failed to copy cookie database: %w", err) + } + defer os.Remove(tempDB) + + db, err := sql.Open("sqlite3", tempDB) + if err != nil { + return "", fmt.Errorf("failed to open cookie database: %w", err) + } + defer db.Close() + + var encryptedValue []byte + var value string + + // Try to get both encrypted_value and value columns + query := `SELECT encrypted_value, value FROM cookies WHERE host_key = ? AND name = ?` + err = db.QueryRow(query, domain, name).Scan(&encryptedValue, &value) + if err != nil { + if err == sql.ErrNoRows { + return "", fmt.Errorf("cookie '%s' not found for domain '%s'", name, domain) + } + return "", fmt.Errorf("failed to query cookie: %w", err) + } + + // If we have a plain text value, return it + if value != "" { + return value, nil + } + + // Otherwise, decrypt the encrypted value + if len(encryptedValue) == 0 { + return "", fmt.Errorf("cookie has no value") + } + + // Debug: check if it's already decrypted (doesn't start with v10/v11) + if len(encryptedValue) > 3 { + prefix := string(encryptedValue[:3]) + if prefix != "v10" && prefix != "v11" { + // Try to return as string if it looks like text + possibleValue := string(encryptedValue) + // Check if it's printable + isPrintable := true + for _, r := range possibleValue { + if r < 32 || r > 126 { + isPrintable = false + break + } + } + if isPrintable && len(possibleValue) > 50 { + return possibleValue, nil + } + } + } + + decrypted, err := cr.decryptChromiumCookie(encryptedValue) + if err != nil { + return "", fmt.Errorf("failed to decrypt cookie: %w", err) + } + + return decrypted, nil +} + +// decryptChromiumCookie decrypts Chrome/Edge encrypted cookies +func (cr *CookieReader) decryptChromiumCookie(encrypted []byte) (string, error) { + if len(encrypted) == 0 { + return "", nil + } + + switch cr.os { + case "darwin": + return cr.decryptChromiumCookieMac(encrypted) + case "windows": + return cr.decryptChromiumCookieWindows(encrypted) + case "linux": + return cr.decryptChromiumCookieLinux(encrypted) + default: + return "", fmt.Errorf("unsupported OS for decryption: %s", cr.os) + } +} + +// decryptChromiumCookieMac decrypts Chrome cookies on macOS +func (cr *CookieReader) decryptChromiumCookieMac(encrypted []byte) (string, error) { + // Check for v10 prefix + if len(encrypted) < 3 || string(encrypted[:3]) != "v10" { + // Not encrypted or old format + return string(encrypted), nil + } + + // Remove v10 prefix + encrypted = encrypted[3:] + + // Get Chrome Safe Storage password from Keychain + password, err := cr.getChromePassword() + if err != nil { + return "", fmt.Errorf("failed to get Chrome password: %w", err) + } + + // Derive key using PBKDF2 + key := pbkdf2.Key([]byte(password), []byte("saltysalt"), 1003, 16, sha1.New) + + // Decrypt using AES-128-CBC + return cr.decryptAES128CBC(key, encrypted) +} + +// getChromePassword retrieves Chrome's Safe Storage password from macOS Keychain +func (cr *CookieReader) getChromePassword() (string, error) { + // Different browsers use different keychain entries + var service, account string + + switch cr.browser { + case Chrome: + service = "Chrome Safe Storage" + account = "Chrome" + case Arc: + service = "Arc Safe Storage" + account = "Arc" + case Edge: + service = "Microsoft Edge Safe Storage" + account = "Microsoft Edge" + default: + service = "Chrome Safe Storage" + account = "Chrome" + } + + // Use the security command to get the password from keychain + cmd := exec.Command("security", "find-generic-password", "-w", "-s", service, "-a", account) + output, err := cmd.Output() + if err != nil { + // Fallback to default password if keychain access fails + return "Chrome Safe Storage", nil + } + return strings.TrimSpace(string(output)), nil +} + +// decryptChromiumCookieWindows decrypts Chrome cookies on Windows +func (cr *CookieReader) decryptChromiumCookieWindows(encrypted []byte) (string, error) { + // On Windows, Chrome uses DPAPI + // This is a placeholder - actual implementation would use Windows DPAPI + return "", errors.New("Windows cookie decryption not implemented") +} + +// decryptChromiumCookieLinux decrypts Chrome cookies on Linux +func (cr *CookieReader) decryptChromiumCookieLinux(encrypted []byte) (string, error) { + // Check for v10 or v11 prefix + if len(encrypted) < 3 { + return string(encrypted), nil + } + + version := string(encrypted[:3]) + if version != "v10" && version != "v11" { + // Not encrypted + return string(encrypted), nil + } + + // Remove version prefix + encrypted = encrypted[3:] + + // Default Chrome password on Linux + password := "peanuts" + + // Derive key using PBKDF2 + key := pbkdf2.Key([]byte(password), []byte("saltysalt"), 1, 16, sha1.New) + + // Decrypt using AES-128-CBC + return cr.decryptAES128CBC(key, encrypted) +} + +// decryptAES128CBC decrypts data using AES-128-CBC +func (cr *CookieReader) decryptAES128CBC(key, encrypted []byte) (string, error) { + if len(encrypted) < aes.BlockSize { + return "", errors.New("encrypted data too short") + } + + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + // Extract IV (first 16 bytes) + iv := encrypted[:aes.BlockSize] + encrypted = encrypted[aes.BlockSize:] + + // Decrypt + mode := cipher.NewCBCDecrypter(block, iv) + decrypted := make([]byte, len(encrypted)) + mode.CryptBlocks(decrypted, encrypted) + + // Remove PKCS7 padding + padding := int(decrypted[len(decrypted)-1]) + if padding > 0 && padding <= aes.BlockSize { + decrypted = decrypted[:len(decrypted)-padding] + } + + // Clean the result - remove any non-printable characters at the beginning + result := string(decrypted) + // Find the first printable character + startIdx := 0 + for i := 0; i < len(result); i++ { + if result[i] >= 32 && result[i] <= 126 { + startIdx = i + break + } + } + + if startIdx > 0 { + result = result[startIdx:] + } + + return strings.TrimSpace(result), nil +} + +// getFirefoxCookie reads a cookie from Firefox cookie database +func (cr *CookieReader) getFirefoxCookie(dbPath, name, domain string) (string, error) { + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + return "", fmt.Errorf("failed to open Firefox cookie database: %w", err) + } + defer db.Close() + + var value string + query := `SELECT value FROM moz_cookies WHERE host = ? AND name = ?` + err = db.QueryRow(query, domain, name).Scan(&value) + if err != nil { + if err == sql.ErrNoRows { + return "", fmt.Errorf("cookie '%s' not found for domain '%s'", name, domain) + } + return "", fmt.Errorf("failed to query Firefox cookie: %w", err) + } + + return value, nil +} + +// getSafariCookie reads a cookie from Safari (macOS only) +func (cr *CookieReader) getSafariCookie(dbPath, name, domain string) (string, error) { + // Safari uses a binary plist format for cookies + // This is a placeholder - actual implementation would parse .binarycookies format + return "", errors.New("Safari cookie reading not implemented") +} + +// copyFile copies a file from src to dst +func copyFile(src, dst string) error { + input, err := os.ReadFile(src) + if err != nil { + return err + } + + err = os.WriteFile(dst, input, 0644) + if err != nil { + return err + } + + return nil +} + +// GetARLFromAnyBrowser tries to get the ARL cookie from any available browser +func GetARLFromAnyBrowser() (string, error) { + browsers := []BrowserType{Chrome, Firefox, Edge, Arc} + if runtime.GOOS == "darwin" { + browsers = append(browsers, Safari) + } + + var lastErr error + for _, browser := range browsers { + reader := NewCookieReader(browser) + arl, err := reader.GetDeezerARL() + if err == nil && arl != "" { + return arl, nil + } + lastErr = err + } + + if lastErr != nil { + return "", fmt.Errorf("failed to get ARL cookie from any browser: %w", lastErr) + } + return "", errors.New("no ARL cookie found in any browser") +} + +// ParseCookieString parses a cookie string and extracts the ARL value +func ParseCookieString(cookieString string) (string, error) { + originalString := strings.TrimSpace(cookieString) + + // Try to handle base64 encoded cookies + if decoded, err := base64.StdEncoding.DecodeString(originalString); err == nil && len(decoded) > 0 { + // Only use decoded if it contains valid text + decodedStr := string(decoded) + if strings.Contains(decodedStr, "arl") || strings.Contains(decodedStr, "ARL") { + cookieString = decodedStr + } else { + cookieString = originalString + } + } else { + cookieString = originalString + } + + // Parse cookie string format: "name=value; name2=value2; ..." + if strings.Contains(cookieString, "=") { + cookies := strings.Split(cookieString, ";") + for _, cookie := range cookies { + parts := strings.SplitN(strings.TrimSpace(cookie), "=", 2) + if len(parts) == 2 && strings.ToLower(parts[0]) == "arl" { + return strings.TrimSpace(parts[1]), nil + } + } + } + + // Check if the string itself is just the ARL value (long alphanumeric string) + if len(cookieString) >= 50 && !strings.Contains(cookieString, "=") && !strings.Contains(cookieString, ";") && !strings.Contains(cookieString, " ") { + // Validate it looks like an ARL token + validChars := true + for _, r := range cookieString { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-') { + validChars = false + break + } + } + if validChars { + return cookieString, nil + } + } + + return "", errors.New("ARL cookie not found in cookie string") +} \ No newline at end of file diff --git a/internal/auth/browser_cookies_test.go b/internal/auth/browser_cookies_test.go new file mode 100644 index 0000000..4543f91 --- /dev/null +++ b/internal/auth/browser_cookies_test.go @@ -0,0 +1,142 @@ +package auth + +import ( + "runtime" + "strings" + "testing" +) + +func TestNewCookieReader(t *testing.T) { + tests := []struct { + name string + browser BrowserType + want BrowserType + }{ + {"Chrome", Chrome, Chrome}, + {"Firefox", Firefox, Firefox}, + {"Safari", Safari, Safari}, + {"Edge", Edge, Edge}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cr := NewCookieReader(tt.browser) + if cr.browser != tt.want { + t.Errorf("NewCookieReader() browser = %v, want %v", cr.browser, tt.want) + } + if cr.os != runtime.GOOS { + t.Errorf("NewCookieReader() os = %v, want %v", cr.os, runtime.GOOS) + } + }) + } +} + +func TestParseCookieString(t *testing.T) { + tests := []struct { + name string + cookieString string + want string + wantErr bool + }{ + { + name: "Standard cookie format", + cookieString: "sid=abc123; arl=xyz789; uid=456", + want: "xyz789", + wantErr: false, + }, + { + name: "ARL only", + cookieString: "arl=myarlvalue123", + want: "myarlvalue123", + wantErr: false, + }, + { + name: "Raw ARL value", + cookieString: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2g3h4i5j6k7l8m9n0o1p2q3r4s5t6u7v8w9x0y1z2", + want: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2g3h4i5j6k7l8m9n0o1p2q3r4s5t6u7v8w9x0y1z2", + wantErr: false, + }, + { + name: "No ARL cookie", + cookieString: "sid=abc123; uid=456", + want: "", + wantErr: true, + }, + { + name: "Empty string", + cookieString: "", + want: "", + wantErr: true, + }, + { + name: "Case insensitive ARL", + cookieString: "ARL=upperCaseValue", + want: "upperCaseValue", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseCookieString(tt.cookieString) + if (err != nil) != tt.wantErr { + t.Errorf("ParseCookieString() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("ParseCookieString() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetCookiePath(t *testing.T) { + // This test checks that getCookiePath returns valid paths for different browsers + browsers := []BrowserType{Chrome, Firefox, Edge} + if runtime.GOOS == "darwin" { + browsers = append(browsers, Safari) + } + + for _, browser := range browsers { + t.Run(string(browser), func(t *testing.T) { + cr := NewCookieReader(browser) + path, err := cr.getCookiePath() + + // We expect either a valid path or a "file not found" error + if err != nil { + // Check if it's a "file not found" error (expected) or another error (unexpected) + if !strings.Contains(err.Error(), "cookie file not found") && + !strings.Contains(err.Error(), "not found") && + !strings.Contains(err.Error(), "no Firefox profile found") { + t.Errorf("getCookiePath() returned unexpected error for %s: %v", browser, err) + } + } else { + // If no error, path should not be empty + if path == "" { + t.Errorf("getCookiePath() returned empty path for %s", browser) + } + } + }) + } +} + +func TestDecryptAES128CBC(t *testing.T) { + // Test basic AES-128-CBC decryption functionality + cr := NewCookieReader(Chrome) + + // This is a basic test with known values + // In real scenarios, the encrypted data would come from the browser + key := []byte("0123456789abcdef") // 16 bytes for AES-128 + + // Test with empty data + _, err := cr.decryptAES128CBC(key, []byte{}) + if err == nil { + t.Error("decryptAES128CBC() should fail with empty data") + } + + // Test with data shorter than block size + _, err = cr.decryptAES128CBC(key, []byte("short")) + if err == nil { + t.Error("decryptAES128CBC() should fail with data shorter than block size") + } +} \ No newline at end of file diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..5612481 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,81 @@ +// internal/models/models.go +package models + +import "time" // Added for potential timestamp fields + +// SourceType indicates the origin of the data (Spotify, Deezer, etc.) +type SourceType string + +const ( + SourceSpotify SourceType = "Spotify" + SourceDeezer SourceType = "Deezer" + SourceTidal SourceType = "Tidal" + SourceUnknown SourceType = "Unknown" + // Add other sources as needed +) + +// Image represents an image URL with dimensions. +type Image struct { + URL string `json:"url"` + Height int `json:"height,omitempty"` + Width int `json:"width,omitempty"` +} + +// Artist represents a music artist. +type Artist struct { + ID string `json:"id,omitempty"` // Service-specific ID + Source SourceType `json:"source,omitempty"` + Name string `json:"name"` + Images []Image `json:"images,omitempty"` +} + +// Album represents a music album. +type Album struct { + ID string `json:"id,omitempty"` // Service-specific ID (often same as SpotifyID for Spotify) + SpotifyID string `json:"spotify_id,omitempty"` // Explicit Spotify ID + Source SourceType `json:"source,omitempty"` + Title string `json:"title"` + Artists []Artist `json:"artists,omitempty"` + Images []Image `json:"images,omitempty"` + ReleaseDate string `json:"release_date,omitempty"` // Consider time.Time if precision is needed + Label string `json:"label,omitempty"` // Record label + TotalTracks int `json:"total_tracks,omitempty"` + UPC string `json:"upc,omitempty"` // Universal Product Code + Genres []string `json:"genres,omitempty"` // Added Genres + AlbumType string `json:"album_type,omitempty"` // Added AlbumType (e.g., "album", "single") +} + +// Track represents a music track. +type Track struct { + ID string `json:"id,omitempty"` // Service-specific ID + SpotifyID string `json:"spotify_id,omitempty"` // Explicit Spotify ID + Source SourceType `json:"source,omitempty"` + Title string `json:"title"` + Artists []Artist `json:"artists,omitempty"` + Album *Album `json:"album,omitempty"` // Changed from string to *Album + DurationMs int `json:"duration_ms,omitempty"` + TrackNumber int `json:"track_number,omitempty"` + DiscNumber int `json:"disc_number,omitempty"` + Explicit bool `json:"explicit,omitempty"` + ISRC string `json:"isrc,omitempty"` // International Standard Recording Code + Images []Image `json:"images,omitempty"` // Often inherited from Album + ReleaseDate string `json:"release_date,omitempty"` // Often inherited from Album + AddedAt *time.Time `json:"added_at,omitempty"` // For playlists + DownloadURL string `json:"download_url,omitempty"` // Potential field for download link + FileSize int64 `json:"file_size,omitempty"` // Potential field for file size + AudioQuality string `json:"audio_quality,omitempty"` // Potential field for quality info +} + +// Playlist represents a music playlist. +type Playlist struct { + ID string `json:"id,omitempty"` // Service-specific ID + SpotifyID string `json:"spotify_id,omitempty"` // Explicit Spotify ID + Source SourceType `json:"source,omitempty"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + OwnerName string `json:"owner_name,omitempty"` + Public bool `json:"public,omitempty"` + TotalTracks int `json:"total_tracks,omitempty"` + Images []Image `json:"images,omitempty"` + Tracks []Track `json:"tracks,omitempty"` // Populated after fetching details +} \ No newline at end of file diff --git a/internal/services/spotify/auth.go b/internal/services/spotify/auth.go new file mode 100644 index 0000000..0800671 --- /dev/null +++ b/internal/services/spotify/auth.go @@ -0,0 +1,344 @@ +package spotify + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "time" // Added for shutdown timeout + + "github.com/google/uuid" + "github.com/zmb3/spotify/v2" + spotifyauth "github.com/zmb3/spotify/v2/auth" + "golang.org/x/oauth2" +) + +const ( + // redirectURI is the endpoint Spotify will redirect to after authorization. + // Needs to match the one registered in the Spotify Developer Dashboard. + redirectURI = "http://localhost:8888/callback" + // tokenFileName is the name of the file to store OAuth tokens. + tokenFileName = "spotify_token.json" +) + +var ( + // scopes define the permissions the application requests from the user. + // Adjust these based on the specific Spotify data you need to access. + scopes = []string{ + spotifyauth.ScopeUserReadPrivate, // Read user's private information + // Add other scopes as needed, e.g.: + // spotifyauth.ScopePlaylistReadPrivate, // Read user's private playlists + // spotifyauth.ScopePlaylistReadCollaborative, // Read user's collaborative playlists + // spotifyauth.ScopeUserLibraryRead, // Read user's saved tracks/albums + // spotifyauth.ScopeUserReadEmail, // Read user's email address + } + // state is a random string to protect against CSRF attacks. + state = uuid.NewString() +) + +// AuthService handles Spotify OAuth2 authentication. +type AuthService struct { + authenticator *spotifyauth.Authenticator + ch chan *spotify.Client // Channel to receive the authenticated client + server *http.Server + configDir string + tokenPath string +} + +// Config holds Spotify API credentials. +// 🔄 TODO: Load these securely, e.g., from environment variables or a config file. +type Config struct { + ClientID string + ClientSecret string +} + +// OAuthConfig returns the OAuth2 config for Spotify authentication. +func (c *Config) OAuthConfig() *oauth2.Config { + return &oauth2.Config{ + ClientID: c.ClientID, + ClientSecret: c.ClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: spotifyauth.AuthURL, + TokenURL: spotifyauth.TokenURL, + }, + RedirectURL: redirectURI, + Scopes: scopes, + } +} + +// NewAuthService creates a new Spotify authentication service. +func NewAuthService(cfg Config) (*AuthService, error) { + configDir, err := getConfigDir() + if err != nil { + return nil, fmt.Errorf("failed to get config directory: %w", err) + } + tokenPath := filepath.Join(configDir, tokenFileName) + + auth := spotifyauth.New( + spotifyauth.WithRedirectURL(redirectURI), + spotifyauth.WithScopes(scopes...), + spotifyauth.WithClientID(cfg.ClientID), + spotifyauth.WithClientSecret(cfg.ClientSecret), + ) + + return &AuthService{ + authenticator: auth, + ch: make(chan *spotify.Client), + configDir: configDir, + tokenPath: tokenPath, + }, nil +} + +// GetAuthURL generates the Spotify authorization URL for the user to visit. +func (s *AuthService) GetAuthURL() string { + return s.authenticator.AuthURL(state) +} + +// getConfigDir finds or creates the application's configuration directory. +func getConfigDir() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + configDir := filepath.Join(homeDir, ".config", "gofi") + if err := os.MkdirAll(configDir, 0700); err != nil { + return "", fmt.Errorf("failed to create config directory '%s': %w", configDir, err) + } + return configDir, nil +} + +// saveToken saves the OAuth2 token to the configuration file. +func (s *AuthService) saveToken(token *oauth2.Token) error { + data, err := json.MarshalIndent(token, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal token: %w", err) + } + if err := os.WriteFile(s.tokenPath, data, 0600); err != nil { + return fmt.Errorf("failed to write token file '%s': %w", s.tokenPath, err) + } + log.Printf("Token saved to %s\n", s.tokenPath) + return nil +} + +// loadToken loads the OAuth2 token from the configuration file. +func (s *AuthService) loadToken() (*oauth2.Token, error) { + data, err := os.ReadFile(s.tokenPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil // No token file exists yet + } + return nil, fmt.Errorf("failed to read token file '%s': %w", s.tokenPath, err) + } + + var token oauth2.Token + if err := json.Unmarshal(data, &token); err != nil { + return nil, fmt.Errorf("failed to unmarshal token from '%s': %w", s.tokenPath, err) + } + return &token, nil +} + +// StartAuthentication initiates the OAuth flow if no token exists or prompts the user. +// It starts a local server to handle the callback. +func (s *AuthService) StartAuthentication(ctx context.Context) (*spotify.Client, error) { + // Try loading existing token + token, err := s.loadToken() + if err != nil { + log.Printf("Warning: could not load existing token: %v", err) + } + + // If token exists and is valid (or refreshable), create client + if token != nil { + // ‼️ FIXME: The underlying http client in `spotify.New` created via `authenticator.Client` + // should handle token refreshing automatically using the `oauth2.TokenSource`. + // We still might want an explicit check here or in GetClient later. + client := spotify.New(s.authenticator.Client(ctx, token)) + log.Println("Using existing Spotify token.") + return client, nil + } + + // No valid token, start the authentication flow + log.Println("No valid Spotify token found. Starting authentication flow...") + fmt.Printf(`GoFi needs permission to access your Spotify account. +`) + fmt.Printf(`Please open the following URL in your browser: + +%s + +`, s.GetAuthURL()) + fmt.Println("Waiting for authorization...") + + // Start the callback server + if err := s.startCallbackServer(); err != nil { + return nil, err + } + defer s.stopCallbackServer(ctx) // Ensure server is stopped + + // Wait for the callback handler to send the authenticated client + select { + case client := <-s.ch: + if client == nil { + return nil, fmt.Errorf("authentication failed during callback") + } + log.Println("Spotify authentication successful.") + return client, nil + case <-ctx.Done(): + return nil, fmt.Errorf("authentication timed out or was cancelled: %w", ctx.Err()) + } +} + +// startCallbackServer starts the local HTTP server to listen for the Spotify callback. +func (s *AuthService) startCallbackServer() error { + // ‼️ FIXME: Ensure this doesn't clash if another instance is running or port is taken. + mux := http.NewServeMux() + mux.HandleFunc("/callback", s.handleCallback) + s.server = &http.Server{ + Addr: ":8888", // Listen on the port specified in redirectURI + Handler: mux, + } + + // Channel to signal server start or error + startErrCh := make(chan error, 1) + + // Run the server in a separate goroutine so it doesn't block. + go func() { + log.Println("Starting callback server on http://localhost:8888") + startErrCh <- nil // Signal successful start attempt (ListenAndServe blocks) + if err := s.server.ListenAndServe(); err != http.ErrServerClosed { + log.Printf("Error starting or running callback server: %v", err) + // Try to send error back if channel is still listened to + select { + case startErrCh <- fmt.Errorf("callback server error: %w", err): + default: + } + // Signal failure via the main client channel if server crashes later + select { + case s.ch <- nil: + default: // Avoid blocking if channel is already closed or full + } + } + }() + + // Wait for server to start or error out immediately + select { + case err := <-startErrCh: + if err != nil { + return err // Return error if server failed to start listening + } + // Server start attempted (doesn't guarantee it's fully ready, but ListenAndServe was called) + log.Println("Callback server listener started.") + return nil + case <-time.After(2 * time.Second): // Timeout for server start + return fmt.Errorf("callback server failed to start within timeout") + + } +} + +// stopCallbackServer gracefully shuts down the callback server. +func (s *AuthService) stopCallbackServer(ctx context.Context) { + if s.server != nil { + log.Println("Shutting down callback server...") + // Create a context with timeout for shutdown + // Use a background context for shutdown if the original ctx is already done. + shutdownCtx := context.Background() + if ctx.Err() == nil { + var cancel context.CancelFunc + shutdownCtx, cancel = context.WithTimeout(ctx, 5*time.Second) + defer cancel() + } else { + // If parent context is done, give shutdown a fixed timeout. + var cancel context.CancelFunc + shutdownCtx, cancel = context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + } + + if err := s.server.Shutdown(shutdownCtx); err != nil { + log.Printf("Callback server shutdown error: %v", err) + } else { + log.Println("Callback server stopped.") + } + s.server = nil + close(s.ch) // Close channel after server stops + } +} + +// handleCallback is the HTTP handler for the Spotify redirect URI. +func (s *AuthService) handleCallback(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() // Use request context + + // Verify the state matches to prevent CSRF + receivedState := r.FormValue("state") + if receivedState != state { + http.Error(w, "State mismatch", http.StatusBadRequest) + log.Printf("State mismatch: expected %s, got %s", state, receivedState) + // Don't send nil to channel here, let the main flow time out or handle missing client + return + } + + // Exchange the authorization code for a token + token, err := s.authenticator.Token(ctx, state, r) // Pass received state back for validation by library? check docs + if err != nil { + http.Error(w, "Couldn't get token: "+err.Error(), http.StatusForbidden) + log.Printf("Error getting token: %v", err) + // Don't send nil to channel here + return + } + + // Save the token + if err := s.saveToken(token); err != nil { + log.Printf("Critical Error: Failed to save token: %v", err) + // This is more critical, maybe return error to user? + http.Error(w, "Failed to save token", http.StatusInternalServerError) + // Don't send nil to channel here + return + } + + // Create a client using the new token + client := spotify.New(s.authenticator.Client(ctx, token)) + + // Send success message to browser + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf(w, `

Authentication successful!

You can close this window now.

`) + log.Println("Callback handled successfully.") + + // Send the authenticated client back to the main flow + // Use non-blocking send in case receiver is gone (e.g., timeout) + select { + case s.ch <- client: + log.Println("Authenticated client sent to channel.") + default: + log.Println("Warning: Failed to send authenticated client to channel (receiver not ready or channel closed).") + } +} + +// GetClient provides an authenticated Spotify client. +// It attempts to load a token, refresh it if necessary. +// It does NOT initiate the full authentication flow here; that's handled by StartAuthentication. +func (s *AuthService) GetClient(ctx context.Context) (*spotify.Client, error) { + token, err := s.loadToken() + if err != nil { + // Don't wrap os.IsNotExist, let caller handle that if needed + return nil, err + } + + if token == nil { + // No token exists, authentication is required. + return nil, fmt.Errorf("spotify token not found, authentication required (run 'gofi auth spotify')") + } + + // Create a client which will automatically handle refreshing + client := spotify.New(s.authenticator.Client(ctx, token)) + + // 🚀 TODO (OPTIMIZATION): Optional: Verify token validity with a lightweight API call. + // _, err = client.CurrentUser(ctx) + // if err != nil { + // log.Printf("Warning: Spotify token validation check failed: %v. Token might be expired or invalid.", err) + // // Depending on the error, could indicate need for re-auth + // return nil, fmt.Errorf("spotify token may be invalid, re-run 'gofi auth spotify': %w", err) + // } + + log.Println("Using authenticated Spotify client from stored token.") + return client, nil +} diff --git a/internal/services/spotify/spotify.go b/internal/services/spotify/spotify.go new file mode 100644 index 0000000..ff78480 --- /dev/null +++ b/internal/services/spotify/spotify.go @@ -0,0 +1,473 @@ +// Package spotify provides services for interacting with the Spotify API. +package spotify + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" // Added for status code checking + "strings" // Added for joining artists/genres + "time" + + // Import actual models + "github.com/d-fi/GoFi/internal/models" + spotify "github.com/zmb3/spotify/v2" + "golang.org/x/oauth2" +) + +// --- Placeholder Model Structs --- +// ‼️ FIXME: Replace these with your actual models from internal/models + +type SourceType string + +const ( + SourceSpotify SourceType = "Spotify" + SourceDeezer SourceType = "Deezer" + // Add other sources as needed +) + +type Image struct { + URL string + Height int + Width int +} + +type Artist struct { + ID string // Spotify ID or your internal ID + Name string + // Add other fields like Images if needed +} + +type Album struct { + ID string // Spotify ID or your internal ID + Title string + Artists []Artist // List of primary artists + Images []Image + ReleaseDate string // YYYY-MM-DD or YYYY + Label string + TotalTracks int + Source SourceType + SpotifyID string `json:",omitempty"` // Ensure this exists if needed elsewhere + UPC string `json:",omitempty"` // External ID + Genres []string + AlbumType string // album, single, compilation + // Add other relevant fields +} + +type Track struct { + ID string // Spotify ID or your internal ID + Title string + Album string // Album Title + AlbumID string // Spotify Album ID or your internal ID + AlbumArtist string // Primary album artist name + Artists []Artist // List of track artists (can differ from album artists) + DurationMs int + Source SourceType + SpotifyID string `json:",omitempty"` // Ensure this exists if needed elsewhere + ISRC string `json:",omitempty"` // External ID + PreviewURL string `json:",omitempty"` + Images []Image // Typically Album images + ReleaseDate string // YYYY-MM-DD or YYYY (from Album) + TrackNumber int + DiscNumber int + Explicit bool + // Add other relevant fields (e.g., DeezerID, Lyrics, FilePath after download) +} + +type Playlist struct { + ID string // Spotify ID or your internal ID + Title string + Description string + OwnerName string + OwnerID string + Images []Image + Source SourceType + SpotifyID string `json:",omitempty"` + Public bool + Collaborative bool + TotalTracks int // Approximate, as it might change + // Add other relevant fields +} + +// --- End Placeholder Model Structs --- + + +// SpotifyService interacts with the Spotify API using an authenticated client. +type SpotifyService struct { + client *spotify.Client +} + +// NewSpotifyService creates a new service instance. +// It requires an authenticated client obtained from AuthService.GetClient(). +func NewSpotifyService(client *spotify.Client) *SpotifyService { + if client == nil { + // This should ideally not happen if GetClient is used correctly + log.Println("Warning: Initializing SpotifyService with a nil client.") + // Return nil or an error? For now, allow it but expect failures later. + return &SpotifyService{client: nil} + } + return &SpotifyService{ + client: client, + } +} + +// --- Mapping Helper Functions --- + +func mapSpotifyImageToGofi(img spotify.Image) models.Image { + return models.Image{ + URL: img.URL, + Height: int(img.Height), // Cast Numeric to int + Width: int(img.Width), // Cast Numeric to int + } +} + +func mapSpotifyImagesToGofi(imgs []spotify.Image) []models.Image { // Return []models.Image + if imgs == nil { + return nil + } + gofiImages := make([]models.Image, len(imgs)) + for i, img := range imgs { + gofiImages[i] = mapSpotifyImageToGofi(img) + } + return gofiImages +} + +// Changed to return models.Artist +func mapSpotifyArtistToGofi(artist spotify.SimpleArtist) models.Artist { + return models.Artist{ + ID: artist.ID.String(), + Name: artist.Name, + Source: models.SourceSpotify, // Use models constant + // Images not available on SimpleArtist + } +} + +// Changed to return []models.Artist +func mapSpotifyArtistsToGofi(artists []spotify.SimpleArtist) []models.Artist { + if artists == nil { + return nil + } + gofiArtists := make([]models.Artist, len(artists)) // Use models.Artist + for i, artist := range artists { + gofiArtists[i] = mapSpotifyArtistToGofi(artist) + } + return gofiArtists +} + +// Maps FullTrack to models.Track +func mapSpotifyFullTrackToGofi(track *spotify.FullTrack) *models.Track { + if track == nil { + return nil + } + isrc, _ := track.ExternalIDs["isrc"] + // Map embedded SimpleAlbum to *models.Album + gofiAlbum := mapSpotifySimpleAlbumToGofi(&track.Album) // Pass the address + + return &models.Track{ + ID: track.ID.String(), + SpotifyID: track.ID.String(), // Populate SpotifyID + Source: models.SourceSpotify, // Use models constant + Title: track.Name, + Artists: mapSpotifyArtistsToGofi(track.Artists), // Returns []models.Artist + Album: gofiAlbum, // Assign *models.Album + DurationMs: int(track.Duration), // Cast spotify.Numeric to int + TrackNumber: int(track.TrackNumber), // Cast spotify.Numeric to int + DiscNumber: int(track.DiscNumber), // Cast spotify.Numeric to int + Explicit: track.Explicit, + ISRC: isrc, + Images: mapSpotifyImagesToGofi(track.Album.Images), // Returns []models.Image + ReleaseDate: track.Album.ReleaseDate, + // AddedAt set contextually + } +} + +// Maps SimpleTrack + SimpleAlbum context to models.Track +// Takes *models.Album as context +func mapSpotifySimpleTrackToGofi(track *spotify.SimpleTrack, albumContext *models.Album) *models.Track { + if track == nil { + return nil // Return nil if track is nil + } + + return &models.Track{ + ID: track.ID.String(), + SpotifyID: track.ID.String(), // Populate SpotifyID + Source: models.SourceSpotify, // Use models constant + Title: track.Name, + Artists: mapSpotifyArtistsToGofi(track.Artists), // Returns []models.Artist + Album: albumContext, // Use provided *models.Album context + DurationMs: int(track.Duration), // Cast spotify.Numeric to int + TrackNumber: int(track.TrackNumber), // Cast spotify.Numeric to int + // DiscNumber not on SimpleTrack + Explicit: track.Explicit, + // ISRC not on SimpleTrack + // Images taken from albumContext + // ReleaseDate taken from albumContext + // AddedAt set contextually + } +} + +// Maps SimpleAlbum to models.Album +func mapSpotifySimpleAlbumToGofi(album *spotify.SimpleAlbum) *models.Album { + if album == nil { + return nil + } + return &models.Album{ + ID: album.ID.String(), + SpotifyID: album.ID.String(), // Populate SpotifyID + Source: models.SourceSpotify, + Title: album.Name, + Artists: mapSpotifyArtistsToGofi(album.Artists), + Images: mapSpotifyImagesToGofi(album.Images), + ReleaseDate: album.ReleaseDate, + AlbumType: album.AlbumType, // Populate AlbumType + // Label, TotalTracks, UPC, Genres not available on SimpleAlbum + } +} + + +// Maps FullAlbum to models.Album +func mapSpotifyAlbumToGofi(album *spotify.FullAlbum) *models.Album { + if album == nil { + return nil + } + upc, _ := album.ExternalIDs["upc"] + // Label field is not available in FullAlbum struct + // Set label to empty string since it doesn't exist in the Spotify API response + label := "" // Label is not available in the FullAlbum struct + // Cast Total via embedded Tracks field + totalTracks := int(album.Tracks.Total) // Cast spotify.Numeric to int + + + return &models.Album{ + ID: album.ID.String(), + SpotifyID: album.ID.String(), // Populate SpotifyID + Source: models.SourceSpotify, // Use models constant + Title: album.Name, + Artists: mapSpotifyArtistsToGofi(album.Artists), // Returns []models.Artist + Images: mapSpotifyImagesToGofi(album.Images), // Returns []models.Image + ReleaseDate: album.ReleaseDate, + Label: label, // Populate Label (direct access) + TotalTracks: totalTracks, // Populate TotalTracks (casted) + UPC: upc, + Genres: album.Genres, // Populate Genres + AlbumType: album.AlbumType, // Populate AlbumType + } +} + +// Maps FullPlaylist to models.Playlist +func mapSpotifyPlaylistToGofi(playlist *spotify.FullPlaylist) *models.Playlist { + if playlist == nil { + return nil + } + // Access IsPublic (not Public) field from the embedded SimplePlaylist + isPublic := playlist.IsPublic // Correct field name in the library + // Cast Total via embedded Tracks field + totalTracks := int(playlist.Tracks.Total) // Cast spotify.Numeric to int + // Access DisplayName via embedded Owner field + ownerName := playlist.Owner.DisplayName + + + return &models.Playlist{ + ID: playlist.ID.String(), + SpotifyID: playlist.ID.String(), // Populate SpotifyID + Source: models.SourceSpotify, // Use models constant + Title: playlist.Name, + Description: playlist.Description, + OwnerName: ownerName, // Populate OwnerName (direct access) + Images: mapSpotifyImagesToGofi(playlist.Images), // Returns []models.Image + Public: isPublic, // Populate Public (direct access) + TotalTracks: totalTracks, // Populate TotalTracks (casted) + // Removed OwnerID, Collaborative as they aren't in models.Playlist + } +} + + +// --- Service Methods --- + +// FetchTrack retrieves a single track's details from Spotify. +func (s *SpotifyService) FetchTrack(ctx context.Context, id string) (*models.Track, error) { // Return *models.Track + if s.client == nil { + return nil, fmt.Errorf("spotify client not initialized") + } + trackID := spotify.ID(id) + fullTrack, err := s.client.GetTrack(ctx, trackID) + if err != nil { + if spotifyErr, ok := err.(*spotify.Error); ok && spotifyErr.Status == http.StatusNotFound { + return nil, fmt.Errorf("spotify track %s not found", id) + } + return nil, fmt.Errorf("failed to get spotify track %s: %w", id, err) + } + + gofiTrack := mapSpotifyFullTrackToGofi(fullTrack) // Returns *models.Track + if gofiTrack == nil { + return nil, fmt.Errorf("failed to map spotify track %s", id) + } + + log.Printf("Fetched Spotify track: %s - %s", gofiTrack.Title, joinArtists(gofiTrack.Artists)) // Use models.Artist slice + return gofiTrack, nil +} + + +// FetchAlbum retrieves album details and its tracks from Spotify. +func (s *SpotifyService) FetchAlbum(ctx context.Context, id string) (*models.Album, []models.Track, error) { + if s.client == nil { + return nil, nil, fmt.Errorf("spotify client not initialized") + } + albumID := spotify.ID(id) + + fullAlbum, err := s.client.GetAlbum(ctx, albumID) + if err != nil { + if spotifyErr, ok := err.(*spotify.Error); ok && spotifyErr.Status == http.StatusNotFound { + return nil, nil, fmt.Errorf("spotify album %s not found", id) + } + return nil, nil, fmt.Errorf("failed to get spotify album %s: %w", id, err) + } + + gofiAlbum := mapSpotifyAlbumToGofi(fullAlbum) // Returns *models.Album + if gofiAlbum == nil { + return nil, nil, fmt.Errorf("failed to map spotify album %s", id) + } + + log.Printf("Fetching tracks for Spotify album: %s (%s)", gofiAlbum.Title, id) + var gofiTracks []models.Track // Use models.Track slice + offset := 0 + limit := 50 + // Map the SimpleAlbum part for context, now returns *models.Album + simpleAlbumForMapping := mapSpotifySimpleAlbumToGofi(&fullAlbum.SimpleAlbum) + + for { + albumTracksPage, err := s.client.GetAlbumTracks(ctx, albumID, spotify.Offset(offset), spotify.Limit(limit)) + if err != nil { + log.Printf("Error fetching album tracks page (offset %d) for %s: %v. Returning partially fetched data.", offset, id, err) + return gofiAlbum, gofiTracks, fmt.Errorf("failed to fetch all album tracks for %s (page offset %d): %w", id, offset, err) + } + if albumTracksPage == nil || len(albumTracksPage.Tracks) == 0 { + break + } + + log.Printf("Fetched page of %d tracks for album %s (offset %d)", len(albumTracksPage.Tracks), id, offset) + for _, simpleTrack := range albumTracksPage.Tracks { + // Pass *models.Album context + gofiTrack := mapSpotifySimpleTrackToGofi(&simpleTrack, simpleAlbumForMapping) // Returns *models.Track + if gofiTrack != nil { + gofiTracks = append(gofiTracks, *gofiTrack) // Append models.Track + } else { + log.Printf("Warning: Failed to map simple track %s from album %s", simpleTrack.ID, id) + } + } + + if len(albumTracksPage.Tracks) < limit { + break + } + offset += len(albumTracksPage.Tracks) + } + + log.Printf("Fetched total %d tracks for Spotify album: %s - %s", len(gofiTracks), gofiAlbum.Title, joinArtists(gofiAlbum.Artists)) // Use models.Artist slice + return gofiAlbum, gofiTracks, nil +} + + +// FetchPlaylist retrieves playlist details and its tracks from Spotify. +func (s *SpotifyService) FetchPlaylist(ctx context.Context, id string) (*models.Playlist, []models.Track, error) { + if s.client == nil { + return nil, nil, fmt.Errorf("spotify client not initialized") + } + playlistID := spotify.ID(id) + + fullPlaylist, err := s.client.GetPlaylist(ctx, playlistID) + if err != nil { + if spotifyErr, ok := err.(*spotify.Error); ok && spotifyErr.Status == http.StatusNotFound { + return nil, nil, fmt.Errorf("spotify playlist %s not found", id) + } + return nil, nil, fmt.Errorf("failed to get spotify playlist %s: %w", id, err) + } + + gofiPlaylist := mapSpotifyPlaylistToGofi(fullPlaylist) // Returns *models.Playlist + if gofiPlaylist == nil { + return nil, nil, fmt.Errorf("failed to map spotify playlist %s", id) + } + + log.Printf("Fetching items for Spotify playlist: %s (%s)", gofiPlaylist.Title, id) + var gofiTracks []models.Track // Use models.Track slice + offset := 0 + limit := 100 + + for { + playlistItemsPage, err := s.client.GetPlaylistItems(ctx, playlistID, spotify.Offset(offset), spotify.Limit(limit)) + if err != nil { + log.Printf("Error fetching playlist items page (offset %d) for %s: %v. Returning partially fetched data.", offset, id, err) + return gofiPlaylist, gofiTracks, fmt.Errorf("failed to fetch all playlist items for %s (page offset %d): %w", id, offset, err) + } + if playlistItemsPage == nil || len(playlistItemsPage.Items) == 0 { + break + } + + log.Printf("Fetched page of %d items for playlist %s (offset %d)", len(playlistItemsPage.Items), id, offset) + for _, item := range playlistItemsPage.Items { + if item.Track.Track != nil { + gofiTrack := mapSpotifyFullTrackToGofi(item.Track.Track) // Returns *models.Track + if gofiTrack != nil { + // Check if AddedAt is not empty before parsing + if item.AddedAt != "" { + // Parse the timestamp string directly using time.Parse with RFC3339 format + parsedTime, err := time.Parse(time.RFC3339, item.AddedAt) + if err != nil { + log.Printf("Warning: Failed to parse AddedAt timestamp '%s' for track %s in playlist %s: %v", item.AddedAt, gofiTrack.ID, id, err) + } else { + gofiTrack.AddedAt = &parsedTime + } + } + gofiTracks = append(gofiTracks, *gofiTrack) // Append models.Track + } else { + log.Printf("Warning: Failed to map track %s from playlist %s", item.Track.Track.ID, id) + } + } else { + itemType := "unknown" + if item.Track.Episode != nil { itemType = "episode" } + log.Printf("Skipping non-track item (type: %s) in playlist %s", itemType, id) + } + } + + if len(playlistItemsPage.Items) < limit { + break + } + offset += len(playlistItemsPage.Items) + } + + log.Printf("Fetched total %d tracks for Spotify playlist: %s", len(gofiTracks), gofiPlaylist.Title) + return gofiPlaylist, gofiTracks, nil +} + +// Simple helper to join artist names for logging (now takes models.Artist) +func joinArtists(artists []models.Artist) string { + if len(artists) == 0 { + return "Unknown Artist(s)" + } + names := make([]string, 0, len(artists)) + for _, a := range artists { + if a.Name != "" { + names = append(names, a.Name) + } + } + if len(names) == 0 { + return "Unknown Artist(s)" // Handle case where all names were empty + } + return strings.Join(names, ", ") +} + +// GetClientFromToken retrieves a Spotify client using a stored token. +// Assumes Config struct (defined elsewhere, e.g., auth.go) has OAuthConfig() method. +func GetClientFromToken(ctx context.Context, token *oauth2.Token, config *Config) (*spotify.Client, error) { + if config == nil { + return nil, errors.New("spotify config cannot be nil") + } + + oauthCfg := config.OAuthConfig() + if oauthCfg == nil { + return nil, errors.New("oauth2 config within spotify config is nil") + } + httpClient := oauthCfg.Client(ctx, token) + client := spotify.New(httpClient) + return client, nil +} diff --git a/internal/ui/display.go b/internal/ui/display.go new file mode 100644 index 0000000..e2e6dfd --- /dev/null +++ b/internal/ui/display.go @@ -0,0 +1,271 @@ +package ui + +import ( + "fmt" + "strings" + "sync" + + "github.com/fatih/color" +) + +// UI color definitions +var ( + InfoColor = color.New(color.FgCyan) + SuccessColor = color.New(color.FgGreen) + ErrorColor = color.New(color.FgRed) + WarningColor = color.New(color.FgYellow) + HeaderColor = color.New(color.FgMagenta, color.Bold) + BoldColor = color.New(color.Bold) + DimColor = color.New(color.Faint) +) + +// Icons for different states +const ( + IconInfo = "ℹ" + IconSuccess = "✓" + IconError = "✗" + IconWarning = "⚠" + IconMusic = "♫" + IconDownload = "↓" + IconSearch = "🔍" + IconFolder = "📁" +) + +// DisplayManager handles all UI output +type DisplayManager struct { + mu sync.Mutex + activeDownloads map[string]*SimpleProgress +} + +// NewDisplayManager creates a new display manager +func NewDisplayManager() *DisplayManager { + return &DisplayManager{ + activeDownloads: make(map[string]*SimpleProgress), + } +} + +// PrintHeader prints a styled header +func (dm *DisplayManager) PrintHeader(text string) { + dm.mu.Lock() + defer dm.mu.Unlock() + + fmt.Println() + HeaderColor.Printf("═══ %s %s ═══\n", IconMusic, text) + fmt.Println() +} + +// PrintInfo prints an info message +func (dm *DisplayManager) PrintInfo(format string, args ...interface{}) { + dm.mu.Lock() + defer dm.mu.Unlock() + + InfoColor.Printf("%s ", IconInfo) + fmt.Printf(format+"\n", args...) +} + +// PrintSuccess prints a success message +func (dm *DisplayManager) PrintSuccess(format string, args ...interface{}) { + dm.mu.Lock() + defer dm.mu.Unlock() + + SuccessColor.Printf("%s ", IconSuccess) + fmt.Printf(format+"\n", args...) +} + +// PrintError prints an error message +func (dm *DisplayManager) PrintError(format string, args ...interface{}) { + dm.mu.Lock() + defer dm.mu.Unlock() + + ErrorColor.Printf("%s ", IconError) + fmt.Printf(format+"\n", args...) +} + +// PrintWarning prints a warning message +func (dm *DisplayManager) PrintWarning(format string, args ...interface{}) { + dm.mu.Lock() + defer dm.mu.Unlock() + + WarningColor.Printf("%s ", IconWarning) + fmt.Printf(format+"\n", args...) +} + +// PrintSearching prints a searching message +func (dm *DisplayManager) PrintSearching(service string) { + dm.mu.Lock() + defer dm.mu.Unlock() + + InfoColor.Printf("%s Searching %s... ", IconSearch, service) +} + +// PrintSearchResult prints the result of a search +func (dm *DisplayManager) PrintSearchResult(success bool) { + if success { + SuccessColor.Printf("%s\n", IconSuccess) + } else { + ErrorColor.Printf("%s\n", IconError) + } +} + +// PrintTrackInfo prints formatted track information +func (dm *DisplayManager) PrintTrackInfo(title, artist, album string, quality int) { + dm.mu.Lock() + defer dm.mu.Unlock() + + fmt.Println() + BoldColor.Println("Track Details:") + fmt.Printf(" %s Title: %s\n", IconMusic, title) + fmt.Printf(" %s Artist: %s\n", IconMusic, artist) + fmt.Printf(" %s Album: %s\n", IconMusic, album) + fmt.Printf(" %s Quality: ", IconMusic) + + switch quality { + case 9: + SuccessColor.Printf("FLAC (Lossless)\n") + case 3: + InfoColor.Printf("MP3 320kbps\n") + case 1: + WarningColor.Printf("MP3 128kbps\n") + default: + fmt.Printf("Quality %d\n", quality) + } + fmt.Println() +} + +// PrintAlbumInfo prints formatted album information +func (dm *DisplayManager) PrintAlbumInfo(title, artist string, trackCount int, quality int) { + dm.mu.Lock() + defer dm.mu.Unlock() + + fmt.Println() + BoldColor.Println("Album Details:") + fmt.Printf(" %s Title: %s\n", IconMusic, title) + fmt.Printf(" %s Artist: %s\n", IconMusic, artist) + fmt.Printf(" %s Tracks: %d\n", IconMusic, trackCount) + fmt.Printf(" %s Quality: ", IconMusic) + + switch quality { + case 9: + SuccessColor.Printf("FLAC (Lossless)\n") + case 3: + InfoColor.Printf("MP3 320kbps\n") + case 1: + WarningColor.Printf("MP3 128kbps\n") + default: + fmt.Printf("Quality %d\n", quality) + } + fmt.Println() +} + +// PrintPlaylistInfo prints formatted playlist information +func (dm *DisplayManager) PrintPlaylistInfo(title string, owner string, trackCount int, quality int) { + dm.mu.Lock() + defer dm.mu.Unlock() + + fmt.Println() + BoldColor.Println("Playlist Details:") + fmt.Printf(" %s Title: %s\n", IconMusic, title) + if owner != "" { + fmt.Printf(" %s Owner: %s\n", IconMusic, owner) + } + fmt.Printf(" %s Tracks: %d\n", IconMusic, trackCount) + fmt.Printf(" %s Quality: ", IconMusic) + + switch quality { + case 9: + SuccessColor.Printf("FLAC (Lossless)\n") + case 3: + InfoColor.Printf("MP3 320kbps\n") + case 1: + WarningColor.Printf("MP3 128kbps\n") + default: + fmt.Printf("Quality %d\n", quality) + } + fmt.Println() +} + +// StartProgress creates and starts a new progress bar +func (dm *DisplayManager) StartProgress(id string, total int64, description string) *SimpleProgress { + dm.mu.Lock() + defer dm.mu.Unlock() + + // Truncate description if too long + maxLen := 40 + if len(description) > maxLen { + description = description[:maxLen-3] + "..." + } + + progress := NewSimpleProgress(description, total) + dm.activeDownloads[id] = progress + return progress +} + +// UpdateProgress updates an existing progress bar +func (dm *DisplayManager) UpdateProgress(id string, current int64) { + dm.mu.Lock() + defer dm.mu.Unlock() + + if progress, exists := dm.activeDownloads[id]; exists { + progress.Update(current) + } +} + +// FinishProgress marks a progress bar as complete +func (dm *DisplayManager) FinishProgress(id string) { + dm.mu.Lock() + defer dm.mu.Unlock() + + if progress, exists := dm.activeDownloads[id]; exists { + progress.Finish() + delete(dm.activeDownloads, id) + } +} + + +// PrintDownloadSummary prints a summary of downloads +func (dm *DisplayManager) PrintDownloadSummary(succeeded, failed, total int) { + dm.mu.Lock() + defer dm.mu.Unlock() + + fmt.Println() + fmt.Println(strings.Repeat("─", 50)) + BoldColor.Println("Download Summary") + fmt.Println(strings.Repeat("─", 50)) + + if succeeded > 0 { + SuccessColor.Printf("%s Succeeded: %d\n", IconSuccess, succeeded) + } + if failed > 0 { + ErrorColor.Printf("%s Failed: %d\n", IconError, failed) + } + fmt.Printf(" Total: %d\n", total) + + if failed == 0 { + fmt.Println() + SuccessColor.Printf("%s All downloads completed successfully!\n", IconSuccess) + } else if succeeded == 0 { + fmt.Println() + ErrorColor.Printf("%s All downloads failed.\n", IconError) + } else { + fmt.Println() + WarningColor.Printf("%s Some downloads failed. Check the errors above.\n", IconWarning) + } + fmt.Println(strings.Repeat("─", 50)) +} + +// PrintFileExists prints a message when a file already exists +func (dm *DisplayManager) PrintFileExists(filename string) { + dm.mu.Lock() + defer dm.mu.Unlock() + + DimColor.Printf("%s File already exists: %s\n", IconSuccess, filename) +} + +// PrintSavePath prints where a file was saved +func (dm *DisplayManager) PrintSavePath(path string) { + dm.mu.Lock() + defer dm.mu.Unlock() + + fmt.Printf("%s Saved to: ", IconFolder) + InfoColor.Println(path) +} \ No newline at end of file diff --git a/internal/ui/simple_progress.go b/internal/ui/simple_progress.go new file mode 100644 index 0000000..5aecc4b --- /dev/null +++ b/internal/ui/simple_progress.go @@ -0,0 +1,122 @@ +package ui + +import ( + "fmt" + "strings" + "sync" + "time" +) + +// SimpleProgress provides a simple progress display without external dependencies +type SimpleProgress struct { + mu sync.Mutex + description string + total int64 + current int64 + startTime time.Time + lastUpdate time.Time +} + +// NewSimpleProgress creates a new simple progress tracker +func NewSimpleProgress(description string, total int64) *SimpleProgress { + return &SimpleProgress{ + description: description, + total: total, + startTime: time.Now(), + lastUpdate: time.Now(), + } +} + +// Update updates the progress +func (sp *SimpleProgress) Update(current int64) { + sp.mu.Lock() + defer sp.mu.Unlock() + + sp.current = current + now := time.Now() + + // Only update display every 100ms to avoid flickering + if now.Sub(sp.lastUpdate) < 100*time.Millisecond && sp.current < sp.total { + return + } + sp.lastUpdate = now + + sp.render() +} + +// Finish completes the progress +func (sp *SimpleProgress) Finish() { + sp.mu.Lock() + defer sp.mu.Unlock() + + sp.current = sp.total + sp.render() + fmt.Print(" ") + SuccessColor.Printf("%s\n", IconSuccess) +} + +// render displays the progress bar +func (sp *SimpleProgress) render() { + if sp.total <= 0 { + // Unknown total, just show a spinner + spinner := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + elapsed := time.Since(sp.startTime) + idx := int(elapsed.Milliseconds()/100) % len(spinner) + // Clear the entire line first, then print + fmt.Printf("\r\033[K%s %s %s", IconDownload, sp.description, spinner[idx]) + return + } + + // Calculate progress percentage + percent := int(float64(sp.current) * 100 / float64(sp.total)) + if percent > 100 { + percent = 100 + } + + // Format sizes + currentMB := float64(sp.current) / 1024 / 1024 + totalMB := float64(sp.total) / 1024 / 1024 + + // Calculate speed + elapsed := time.Since(sp.startTime).Seconds() + if elapsed < 0.1 { + elapsed = 0.1 + } + speedMBps := currentMB / elapsed + + // Calculate ETA + var eta string + if speedMBps > 0 && sp.current < sp.total { + remainingMB := totalMB - currentMB + remainingSec := int(remainingMB / speedMBps) + if remainingSec > 0 { + eta = fmt.Sprintf(" - %ds remaining", remainingSec) + } + } + + // Build progress bar + barWidth := 20 + filled := int(float64(barWidth) * float64(percent) / 100) + if filled > barWidth { + filled = barWidth + } + + bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled) + + // Clear the entire line first, then print progress + // \r moves cursor to beginning, \033[K clears from cursor to end of line + fmt.Printf("\r\033[K%s %s [%s] %d%% (%.1f/%.1f MB, %.1f MB/s)%s", + IconDownload, + sp.description, + bar, + percent, + currentMB, + totalMB, + speedMBps, + eta) +} + +// Clear clears the progress line +func (sp *SimpleProgress) Clear() { + fmt.Printf("\r\033[K") +} \ No newline at end of file diff --git a/internal/utils/urlparser.go b/internal/utils/urlparser.go new file mode 100644 index 0000000..ad8bbb9 --- /dev/null +++ b/internal/utils/urlparser.go @@ -0,0 +1,118 @@ +// internal/utils/urlparser.go +package utils + +import ( + "fmt" + "net/url" + "regexp" + "strings" +) + +// ParsedURLType defines the type of Spotify content. +type ParsedURLType string + +const ( + SpotifyTrack ParsedURLType = "spotify:track" + SpotifyAlbum ParsedURLType = "spotify:album" + SpotifyPlaylist ParsedURLType = "spotify:playlist" + DeezerTrack ParsedURLType = "deezer:track" + DeezerAlbum ParsedURLType = "deezer:album" + DeezerPlaylist ParsedURLType = "deezer:playlist" + UnknownURL ParsedURLType = "unknown" +) + +// ParsedURLInfo holds the parsed information from a music URL. +type ParsedURLInfo struct { + Source ParsedURLType // e.g., "spotify", "deezer", "tidal" + Type ParsedURLType // e.g., SpotifyTrack, SpotifyAlbum + ID string // The unique identifier for the track, album, or playlist + URL string +} + +var ( + // Regex for open.spotify.com URLs + spotifyURLRegex = regexp.MustCompile(`https?://open\.spotify\.com/(intl-\w+/)?(track|album|playlist)/([a-zA-Z0-9]+)`) + // Regex for spotify: URI scheme + spotifyURIRegex = regexp.MustCompile(`spotify:(track|album|playlist):([a-zA-Z0-9]+)`) + // Regex for deezer.com URLs + deezerURLRegex = regexp.MustCompile(`https?://(?:www\.)?deezer\.com/(?:\w+/)?(track|album|playlist)/(\d+)`) +) + +// ParseMusicURL analyzes a string to determine if it's a supported music URL +// and extracts the service, type, and ID. +func ParseMusicURL(inputURL string) (*ParsedURLInfo, error) { + inputURL = strings.TrimSpace(inputURL) + if inputURL == "" { + return nil, fmt.Errorf("input URL cannot be empty") + } + + // Check Spotify URLs + if matches := spotifyURLRegex.FindStringSubmatch(inputURL); len(matches) == 4 { + id := matches[3] + urlType := matches[2] + var parsedType ParsedURLType + switch urlType { + case "track": + parsedType = SpotifyTrack + case "album": + parsedType = SpotifyAlbum + case "playlist": + parsedType = SpotifyPlaylist + default: + return nil, fmt.Errorf("unknown spotify URL type: %s", urlType) + } + return &ParsedURLInfo{Source: "spotify", Type: parsedType, ID: id, URL: inputURL}, nil + } + + // Check Spotify URIs + if matches := spotifyURIRegex.FindStringSubmatch(inputURL); len(matches) == 3 { + id := matches[2] + urlType := matches[1] + var parsedType ParsedURLType + switch urlType { + case "track": + parsedType = SpotifyTrack + case "album": + parsedType = SpotifyAlbum + case "playlist": + parsedType = SpotifyPlaylist + default: + return nil, fmt.Errorf("unknown spotify URI type: %s", urlType) + } + return &ParsedURLInfo{Source: "spotify", Type: parsedType, ID: id, URL: inputURL}, nil + } + + // Check Deezer URLs + if matches := deezerURLRegex.FindStringSubmatch(inputURL); len(matches) == 3 { + id := matches[2] + urlType := matches[1] + var parsedType ParsedURLType + switch urlType { + case "track": + parsedType = DeezerTrack + case "album": + parsedType = DeezerAlbum + case "playlist": + parsedType = DeezerPlaylist + default: + return nil, fmt.Errorf("unknown deezer URL type: %s", urlType) + } + return &ParsedURLInfo{Source: "deezer", Type: parsedType, ID: id, URL: inputURL}, nil + } + + // Attempt generic URL parsing if specific patterns fail + parsed, err := url.Parse(inputURL) + if err == nil && parsed.Scheme != "" && parsed.Host != "" { + // Could potentially add more generic checks here based on hostname + if strings.Contains(parsed.Host, "spotify.com") { + // Generic Spotify URL detected, but couldn't determine type/ID + return nil, fmt.Errorf("detected Spotify URL, but could not extract track/album/playlist ID: %s", inputURL) + } + if strings.Contains(parsed.Host, "deezer.com") { + // Generic Deezer URL detected, but couldn't determine type/ID + return nil, fmt.Errorf("detected Deezer URL, but could not extract track/album/playlist ID: %s", inputURL) + } + } + + return nil, fmt.Errorf("unsupported or unrecognized music URL format: %s", inputURL) +} \ No newline at end of file diff --git a/logger/zerolog.go b/logger/zerolog.go index 00635d5..826dc8d 100644 --- a/logger/zerolog.go +++ b/logger/zerolog.go @@ -28,6 +28,11 @@ func init() { log.Logger = logger } +// SetLogLevel sets the log level +func SetLogLevel(level zerolog.Level) { + log.Logger = log.Logger.Level(level) +} + func Debug(msg string, args ...interface{}) { log.Debug().Msgf(msg, args...) } @@ -46,4 +51,4 @@ func Error(msg string, args ...interface{}) { func Fatal(msg string, args ...interface{}) { log.Fatal().Msgf(msg, args...) -} +} \ No newline at end of file diff --git a/request/client.go b/request/client.go index 6a09dae..f609f34 100644 --- a/request/client.go +++ b/request/client.go @@ -4,6 +4,7 @@ import ( "crypto/tls" "encoding/json" "fmt" + "os" "time" "github.com/d-fi/GoFi/logger" @@ -38,6 +39,18 @@ func init() { SetRetryCount(2). SetRetryWaitTime(2 * time.Second). SetRetryMaxWaitTime(5 * time.Second) + + // Attempt to auto-initialize from environment variable + arl := os.Getenv("DEEZER_ARL") + if arl != "" { + logger.Debug("Found DEEZER_ARL environment variable, auto-initializing...") + _, err := InitDeezerAPI(arl) + if err != nil { + logger.Error("Failed to auto-initialize Deezer API: %v", err) + } else { + logger.Debug("Successfully auto-initialized Deezer API with ARL from environment") + } + } } // InitDeezerAPI initializes the Deezer API and sets up a session refresh ticker @@ -45,9 +58,9 @@ func InitDeezerAPI(arl string) (string, error) { userArl = arl logger.Debug("Initializing Deezer API with ARL length: %d", len(arl)) - if len(arl) != 192 { + if len(arl) < 100 { logger.Debug("Invalid ARL length: %d", len(arl)) - return "", fmt.Errorf("invalid arl, length should be 192 characters; you have provided %d characters", len(arl)) + return "", fmt.Errorf("invalid arl, length should be long enough; you have provided %d characters", len(arl)) } resp, err := Client.R(). @@ -80,6 +93,10 @@ func InitDeezerAPI(arl string) (string, error) { sessionID = data.Results.Session Client.SetQueryParam("sid", sessionID) + + // Set Cookie header globally to ensure all requests include the ARL + Client.SetHeader("Cookie", "arl="+arl) + logger.Debug("Deezer API initialized successfully, session ID: %s", sessionID) // Start the session refresh ticker if not already running @@ -101,6 +118,16 @@ func InitDeezerAPI(arl string) (string, error) { return sessionID, nil } +// GetCurrentARL returns the ARL currently being used +func GetCurrentARL() string { + return userArl +} + +// IsInitialized returns whether the Deezer API has been initialized +func IsInitialized() bool { + return sessionID != "" && userArl != "" +} + // refreshSession refreshes the Deezer session using the ARL func refreshSession() (string, error) { logger.Debug("Refreshing Deezer session with ARL") @@ -133,4 +160,4 @@ func refreshSession() (string, error) { Client.SetQueryParam("sid", sessionID) logger.Debug("Session refreshed successfully, new session ID: %s", sessionID) return sessionID, nil -} +} \ No newline at end of file diff --git a/request/client_test.go b/request/client_test.go index d9d042a..0fc1f32 100644 --- a/request/client_test.go +++ b/request/client_test.go @@ -1,15 +1,20 @@ package request import ( - "os" "testing" + + "github.com/d-fi/GoFi/internal/auth" ) // Harder, Better, Faster, Stronger by Daft Punk const SNG_ID = "3135556" func TestInitDeezerAPI(t *testing.T) { - arl := os.Getenv("DEEZER_ARL") + // Try to get ARL from various sources (env, browser cookies, etc.) + arl, err := auth.GetARLToken() + if err != nil { + t.Skip("Skipping test: No valid ARL token available") + } session, err := InitDeezerAPI(arl) if err != nil { diff --git a/request/request.go b/request/request.go index dc1bcd0..e49126d 100644 --- a/request/request.go +++ b/request/request.go @@ -3,6 +3,7 @@ package request import ( "encoding/json" "fmt" + "os" "time" "github.com/d-fi/GoFi/logger" @@ -27,6 +28,20 @@ func checkResponse(data []byte) (json.RawMessage, error) { switch errVal := apiResponse.Error.(type) { case string: + if errVal == "NEED_API_AUTH_REQUIRED" || errVal == "NEED_API_AUTH" { + // Try to re-initialize with ARL if not already done + arl := os.Getenv("DEEZER_ARL") + if arl != "" && !IsInitialized() { + logger.Debug("Auth required, trying to initialize with ARL from environment...") + _, initErr := InitDeezerAPI(arl) + if initErr != nil { + logger.Error("Failed to initialize with ARL from environment: %v", initErr) + } else { + logger.Debug("Successfully initialized with ARL, retrying request") + return nil, fmt.Errorf("API auth initialized, please retry: %s", errVal) + } + } + } logger.Debug("API error: %s", errVal) return nil, fmt.Errorf("API error: %s", errVal) case map[string]interface{}: @@ -49,6 +64,9 @@ func Request(body map[string]interface{}, method string) ([]byte, error) { return cachedData, nil } + // Ensure ARL cookie is set + ensureAuth() + logger.Debug("Making request with method: %s", method) resp, err := Client.R(). SetBody(body). @@ -79,6 +97,9 @@ func RequestGet(method string, params map[string]interface{}) ([]byte, error) { return cachedData, nil } + // Ensure ARL cookie is set + ensureAuth() + queryParams := utils.ConvertToQueryParams(params) logger.Debug("Making GET request with method: %s", method) resp, err := Client.R(). @@ -109,6 +130,9 @@ func RequestPublicApi(slug string) ([]byte, error) { return cachedData, nil } + // Ensure ARL cookie is set for auth-required endpoints + ensureAuth() + logger.Debug("Making public API request: %s", slug) resp, err := Client.R().Get("https://api.deezer.com" + slug) if err != nil { @@ -130,3 +154,19 @@ func RequestPublicApi(slug string) ([]byte, error) { logger.Debug("Public API request successful, response cached") return results, nil } + +// ensureAuth makes sure the API is initialized with an ARL token if available +func ensureAuth() { + if !IsInitialized() { + arl := os.Getenv("DEEZER_ARL") + if arl != "" { + logger.Debug("Auto-initializing with ARL from environment...") + _, err := InitDeezerAPI(arl) + if err != nil { + logger.Error("Failed to auto-initialize: %v", err) + } else { + logger.Debug("Successfully auto-initialized with ARL") + } + } + } +} \ No newline at end of file diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..ca83878 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,133 @@ +# GoFi Installation Script for Windows +# This script downloads and installs the latest GoFi binary for Windows + +$ErrorActionPreference = "Stop" + +# Configuration +$repo = "d-fi/GoFi" +$binaryName = "gofi.exe" +$installDir = "$env:LOCALAPPDATA\Programs\GoFi" + +# Colors for output +function Write-Success { param($Message) Write-Host $Message -ForegroundColor Green } +function Write-Info { param($Message) Write-Host $Message -ForegroundColor Yellow } +function Write-Error { param($Message) Write-Host $Message -ForegroundColor Red } + +Write-Success "GoFi Installation Script for Windows" +Write-Host "====================================" + +# Get the latest release +function Get-LatestRelease { + Write-Info "Fetching latest release..." + + try { + $apiUrl = "https://api.github.com/repos/$repo/releases/latest" + $release = Invoke-RestMethod -Uri $apiUrl -Headers @{"Accept"="application/vnd.github.v3+json"} + + # Find the Windows binary + $asset = $release.assets | Where-Object { $_.name -eq "gofi-windows-amd64.zip" } + + if (-not $asset) { + throw "Could not find Windows binary in latest release" + } + + return $asset.browser_download_url + } + catch { + Write-Error "Failed to get latest release: $_" + exit 1 + } +} + +# Download and install +function Install-GoFi { + param($DownloadUrl) + + # Create temp directory + $tempDir = New-TemporaryFile | %{ Remove-Item $_; New-Item -ItemType Directory -Path $_ } + $zipPath = Join-Path $tempDir "gofi.zip" + + try { + # Download + Write-Info "Downloading GoFi..." + Invoke-WebRequest -Uri $DownloadUrl -OutFile $zipPath + + # Extract + Write-Info "Extracting archive..." + Expand-Archive -Path $zipPath -DestinationPath $tempDir -Force + + # Create install directory + if (-not (Test-Path $installDir)) { + Write-Info "Creating install directory: $installDir" + New-Item -ItemType Directory -Path $installDir -Force | Out-Null + } + + # Find the executable + $exePath = Get-ChildItem -Path $tempDir -Filter "*.exe" -Recurse | Select-Object -First 1 + + if (-not $exePath) { + throw "Could not find executable in archive" + } + + # Copy to install directory + $targetPath = Join-Path $installDir $binaryName + Write-Info "Installing to: $targetPath" + Copy-Item -Path $exePath.FullName -Destination $targetPath -Force + + # Add to PATH if not already there + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + if ($userPath -notlike "*$installDir*") { + Write-Info "Adding GoFi to PATH..." + [Environment]::SetEnvironmentVariable( + "Path", + "$userPath;$installDir", + "User" + ) + $env:Path = "$env:Path;$installDir" + Write-Success "Added $installDir to PATH" + Write-Info "You may need to restart your terminal for PATH changes to take effect" + } + + Write-Success "✓ GoFi has been installed successfully!" + Write-Success "Installation location: $targetPath" + + # Verify installation + if (Get-Command gofi -ErrorAction SilentlyContinue) { + $version = & gofi --version 2>$null + Write-Success "GoFi is available in PATH" + if ($version) { + Write-Success "Version: $version" + } + } else { + Write-Info "Run 'gofi --help' to get started (you may need to restart your terminal first)" + } + } + finally { + # Clean up + Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +# Main +try { + # Check if running as administrator (optional, but recommended) + $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator") + if (-not $isAdmin) { + Write-Info "Note: Running without administrator privileges. Installation will be for current user only." + } + + # Allow custom install directory + if ($args.Count -gt 0) { + $installDir = $args[0] + Write-Info "Using custom install directory: $installDir" + } + + $downloadUrl = Get-LatestRelease + Write-Success "Download URL: $downloadUrl" + + Install-GoFi -DownloadUrl $downloadUrl +} +catch { + Write-Error "Installation failed: $_" + exit 1 +} \ No newline at end of file diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..fa937d8 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# +# GoFi Installation Script +# This script downloads and installs the latest GoFi binary for your platform +# + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# GitHub repository +REPO="d-fi/GoFi" +INSTALL_DIR="/usr/local/bin" +BINARY_NAME="gofi" + +# Detect OS and Architecture +detect_platform() { + OS=$(uname -s | tr '[:upper:]' '[:lower:]') + ARCH=$(uname -m) + + case "$OS" in + darwin) + PLATFORM="darwin" + ;; + linux) + PLATFORM="linux" + ;; + *) + echo -e "${RED}Unsupported operating system: $OS${NC}" + exit 1 + ;; + esac + + case "$ARCH" in + x86_64|amd64) + ARCH="amd64" + ;; + arm64|aarch64) + ARCH="arm64" + ;; + *) + echo -e "${RED}Unsupported architecture: $ARCH${NC}" + exit 1 + ;; + esac + + echo -e "${GREEN}Detected platform: ${PLATFORM}-${ARCH}${NC}" +} + +# Get the latest release download URL +get_download_url() { + local api_url="https://api.github.com/repos/${REPO}/releases/latest" + local download_file="gofi-${PLATFORM}-${ARCH}.tar.gz" + + echo -e "${YELLOW}Fetching latest release...${NC}" + + # Use curl to get the latest release data + local release_data=$(curl -s "$api_url") + + # Extract download URL using grep and sed (more portable than jq) + local download_url=$(echo "$release_data" | grep -o "\"browser_download_url\": \"[^\"]*${download_file}\"" | sed 's/.*: "\(.*\)"/\1/') + + if [ -z "$download_url" ]; then + echo -e "${RED}Could not find download URL for ${download_file}${NC}" + echo -e "${RED}Please check https://github.com/${REPO}/releases for available downloads${NC}" + exit 1 + fi + + echo "$download_url" +} + +# Download and install the binary +install_binary() { + local download_url="$1" + local temp_dir=$(mktemp -d) + local archive_path="${temp_dir}/gofi.tar.gz" + + echo -e "${YELLOW}Downloading GoFi...${NC}" + curl -L -o "$archive_path" "$download_url" + + echo -e "${YELLOW}Extracting archive...${NC}" + tar -xzf "$archive_path" -C "$temp_dir" + + # Find the binary (it should be named gofi-platform-arch) + local binary_path=$(find "$temp_dir" -name "gofi-*" -type f | head -n 1) + + if [ -z "$binary_path" ]; then + echo -e "${RED}Could not find binary in archive${NC}" + rm -rf "$temp_dir" + exit 1 + fi + + # Check if we need sudo + if [ -w "$INSTALL_DIR" ]; then + echo -e "${YELLOW}Installing to ${INSTALL_DIR}/${BINARY_NAME}...${NC}" + mv "$binary_path" "${INSTALL_DIR}/${BINARY_NAME}" + chmod +x "${INSTALL_DIR}/${BINARY_NAME}" + else + echo -e "${YELLOW}Installing to ${INSTALL_DIR}/${BINARY_NAME} (requires sudo)...${NC}" + sudo mv "$binary_path" "${INSTALL_DIR}/${BINARY_NAME}" + sudo chmod +x "${INSTALL_DIR}/${BINARY_NAME}" + fi + + # Clean up + rm -rf "$temp_dir" + + echo -e "${GREEN}✓ GoFi has been installed successfully!${NC}" + echo -e "${GREEN}Run 'gofi --help' to get started${NC}" +} + +# Verify installation +verify_installation() { + if command -v gofi &> /dev/null; then + local version=$(gofi --version 2>/dev/null || echo "unknown") + echo -e "${GREEN}GoFi is installed at: $(which gofi)${NC}" + echo -e "${GREEN}Version: $version${NC}" + else + echo -e "${RED}Installation verification failed${NC}" + exit 1 + fi +} + +# Main installation flow +main() { + echo -e "${GREEN}GoFi Installation Script${NC}" + echo "========================" + + # Check for curl + if ! command -v curl &> /dev/null; then + echo -e "${RED}Error: curl is required but not installed${NC}" + echo "Please install curl and try again" + exit 1 + fi + + detect_platform + + # Allow custom install directory + if [ -n "$1" ]; then + INSTALL_DIR="$1" + echo -e "${YELLOW}Using custom install directory: $INSTALL_DIR${NC}" + fi + + # Create install directory if it doesn't exist + if [ ! -d "$INSTALL_DIR" ]; then + echo -e "${YELLOW}Creating install directory: $INSTALL_DIR${NC}" + if [ -w "$(dirname "$INSTALL_DIR")" ]; then + mkdir -p "$INSTALL_DIR" + else + sudo mkdir -p "$INSTALL_DIR" + fi + fi + + local download_url=$(get_download_url) + echo -e "${GREEN}Download URL: $download_url${NC}" + + install_binary "$download_url" + verify_installation +} + +# Run main function +main "$@" \ No newline at end of file diff --git a/scripts/prd.txt b/scripts/prd.txt new file mode 100644 index 0000000..78895a7 --- /dev/null +++ b/scripts/prd.txt @@ -0,0 +1,244 @@ + +# Overview +GoFi is a high-performance music download tool that provides unified access to multiple music streaming platforms through a simple command-line interface. It solves the problem of fragmented music libraries by allowing users to download their favorite tracks, albums, and playlists from services like Deezer and Spotify in various quality formats including lossless FLAC. The tool is designed for music enthusiasts who want to maintain local copies of their music in high quality, with proper metadata and organization. + +# Core Features +## Multi-Platform Support +- Downloads music from Deezer (direct) and Spotify (via Deezer matching) +- Automatic URL detection for both platforms +- OAuth2 authentication for Spotify integration +- ARL token-based authentication for Deezer + +## High-Quality Downloads +- Multiple quality options: MP3 128kbps, MP3 320kbps, and lossless FLAC +- Automatic metadata tagging (ID3 for MP3, Vorbis comments for FLAC) +- Album artwork embedding with appropriate resolution per quality +- Batch downloading for albums and playlists + +## Smart File Management +- Automatic file existence checking to avoid re-downloads +- Customizable output directory structure +- Sanitized filenames for cross-platform compatibility +- Artist - Track naming convention + +## Beautiful CLI Experience +- Modern terminal UI with colored output +- Real-time progress bars for downloads +- Clear status indicators and error messages +- Support for both legacy flag-based and modern Cobra-based CLI + +# User Experience +## User Personas +- **Music Collectors**: Users who want to maintain high-quality local music libraries +- **DJ/Producers**: Professionals needing offline access to tracks in specific formats +- **Audiophiles**: Users demanding lossless quality for their listening experience +- **Playlist Curators**: Users who want to backup their carefully crafted playlists + +## Key User Flows +1. **First-time Setup**: User authenticates with Spotify (if needed) → Sets preferred quality → Ready to download +2. **Single Track Download**: User provides track URL → Tool fetches metadata → Downloads with progress → File saved with metadata +3. **Playlist/Album Download**: User provides playlist URL → Tool fetches all tracks → Concurrent downloads with progress → Organized file structure created +4. **Cross-Platform Download**: User provides Spotify URL → Tool finds Deezer match → Downloads from Deezer → Maintains Spotify metadata + +## UI/UX Considerations +- Clear, color-coded output for different message types (info, success, error) +- Non-intrusive progress indicators that don't clutter the terminal +- Intuitive command structure matching user expectations +- Helpful error messages with actionable solutions + + +# Technical Architecture +## System Components +### API Clients +- **Deezer Client**: Direct API access for content fetching and downloading +- **Spotify Client**: OAuth2-based client for metadata and content discovery +- **Matching Service**: Intelligent service to match Spotify content with Deezer equivalents + +### Download Engine +- **URL Parser**: Identifies content type and platform from URLs +- **Download Manager**: Handles concurrent downloads with retry logic +- **Progress Tracker**: Real-time download progress monitoring +- **Quality Selector**: Determines optimal download quality based on user preferences and account capabilities + +### Metadata System +- **Tag Writer**: Applies ID3v2.4 tags to MP3 files +- **FLAC Metadata**: Writes Vorbis comments to FLAC files +- **Cover Art Manager**: Downloads and embeds album artwork at appropriate resolutions + +### User Interface +- **Display Manager**: Coordinates terminal output with proper formatting +- **Progress Bar System**: Custom implementation for smooth progress updates +- **Color Manager**: Consistent color coding for different message types + +## Data Models +### Track +- ID, Title, Artist, Album, Duration +- ISRC, Track Number, Disc Number +- Quality Options, File Size +- Download URL, Cover Art URL + +### Album +- ID, Title, Artist, Release Date +- Track List, Total Duration +- Cover Art URLs (multiple resolutions) +- Label, UPC + +### Playlist +- ID, Title, Owner +- Track List, Total Duration +- Public/Private status +- Last Modified + +## APIs and Integrations +### Deezer API +- Private API for high-quality streams +- Public API for metadata +- Authentication via ARL token +- Track URL generation with CDN support + +### Spotify Web API +- OAuth2 authentication flow +- Track/Album/Playlist metadata +- Search functionality +- User library access + +### Content Delivery +- Direct HTTPS downloads from Deezer CDN +- Chunked transfer support +- Resume capability for interrupted downloads + +## Infrastructure Requirements +- Go 1.19+ runtime +- Network access to music service APIs +- Local storage for downloads and configuration +- OAuth2 callback handling (localhost:8080) + +# Development Roadmap +## MVP Requirements +### Core Download Functionality +- Deezer track download with MP3 320kbps quality +- Basic metadata tagging (title, artist, album) +- Simple CLI with URL input +- File existence checking + +### Authentication System +- Deezer ARL token support +- Configuration file management +- Environment variable support + +### Error Handling +- Network error recovery +- Invalid URL detection +- Authentication failure handling + +## Phase 2: Enhanced Features +### Multi-Format Support +- FLAC download capability +- Multiple quality options +- Format-specific metadata handling + +### Batch Operations +- Album download support +- Playlist download support +- Concurrent download system + +### Improved UI +- Progress bars implementation +- Colored output system +- Status indicators + +## Phase 3: Platform Expansion +### Spotify Integration +- OAuth2 authentication flow +- Spotify-to-Deezer content matching +- Metadata preservation from Spotify + +### Advanced Features +- Download queue management +- Partial download resume +- Bandwidth throttling options + +## Phase 4: Polish and Optimization +### Performance Improvements +- Connection pooling +- Optimized concurrent downloads +- Memory usage optimization + +### User Experience +- Configuration profiles +- Download history +- Advanced search capabilities + +# Logical Dependency Chain +## Foundation (Must be built first) +1. **Request Client**: HTTP client with proper headers and session management +2. **Configuration System**: Load/save settings and credentials +3. **Basic CLI Structure**: Command parsing and execution framework + +## Core Features (Build on foundation) +1. **Deezer API Client**: Depends on Request Client +2. **URL Parser**: Standalone utility +3. **Download Engine**: Depends on API Client and URL Parser +4. **Metadata System**: Depends on successful downloads + +## Enhanced Features (Require core features) +1. **Progress Tracking**: Depends on Download Engine +2. **Batch Downloads**: Depends on single track downloads working +3. **Quality Selection**: Depends on API Client capabilities + +## Platform Features (Can be built independently after core) +1. **Spotify Auth**: Independent OAuth2 implementation +2. **Content Matching**: Depends on both API clients +3. **Cross-platform Downloads**: Depends on matching service + +# Risks and Mitigations +## Technical Challenges +### API Stability +- **Risk**: Music service APIs may change without notice +- **Mitigation**: Abstract API calls, implement versioning, maintain fallback options + +### Rate Limiting +- **Risk**: Services may throttle or block excessive requests +- **Mitigation**: Implement request queuing, respect rate limits, add delays + +### Content Matching Accuracy +- **Risk**: Spotify to Deezer matching may not always find correct tracks +- **Mitigation**: Implement fuzzy matching, provide manual search option, show match confidence + +## Legal Considerations +### Terms of Service Compliance +- **Risk**: Tool usage may violate service terms +- **Mitigation**: Require user authentication, respect service limitations, educational use disclaimer + +### Content Rights +- **Risk**: Downloaded content distribution concerns +- **Mitigation**: Personal use only disclaimer, no sharing features, respect DRM + +## Resource Constraints +### Network Bandwidth +- **Risk**: Large downloads may consume significant bandwidth +- **Mitigation**: Implement pause/resume, bandwidth limiting, off-peak scheduling + +### Storage Space +- **Risk**: Music collections can be very large +- **Mitigation**: Pre-download space checking, compression options, selective downloading + +# Appendix +## Research Findings +### Music Service Landscape +- Deezer provides higher quality streams than many competitors +- Spotify has the largest catalog but limited download API +- Content availability varies by region + +### User Behavior Patterns +- Users typically download entire albums or playlists +- Quality preferences vary: audiophiles want FLAC, casual users prefer MP3 +- Metadata accuracy is crucial for library management + +### Technical Specifications +- MP3 128kbps: ~1MB per minute +- MP3 320kbps: ~2.5MB per minute +- FLAC: ~30MB per 4-minute track +- Optimal concurrent downloads: 3-5 threads +- Recommended progress update interval: 100ms + \ No newline at end of file diff --git a/scripts/prd_cli_enhancements.txt b/scripts/prd_cli_enhancements.txt new file mode 100644 index 0000000..1ec96b2 --- /dev/null +++ b/scripts/prd_cli_enhancements.txt @@ -0,0 +1,103 @@ + +# Overview +GoFi is a command-line music download tool that currently provides a functional but basic interface for downloading music from Deezer and Spotify. The tool needs enhancements to improve user experience through interactive prompts, better visual feedback, and configuration management via environment variables. + +# Core Features +## Interactive CLI with Color Output +- Interactive prompts when command-line flags are not provided +- Colored output for different message types (success in green, errors in red, warnings in yellow, info in blue) +- Progress indicators and spinners for long-running operations +- Visual hierarchy with proper spacing and formatting +- Interactive selection menus for quality levels and other options +- Confirmation prompts before downloads and destructive operations + +## Environment Variable Support +- Automatic detection of GOFI_* environment variables +- Support for GOFI_OUTPUT_DIR to set default download directory +- Support for GOFI_QUALITY to set default audio quality (1, 3, or 9) +- Support for GOFI_LOG_LEVEL to set default logging verbosity +- Environment variables should be overridable by command-line flags + +# User Experience +Users should have a delightful experience when using GoFi: +- New users can run `gofi download` without any flags and be guided through the process +- Power users can set environment variables to avoid repetitive flag entry +- Clear visual feedback throughout the download process +- Consistent color scheme across all output types +- Professional appearance similar to modern CLI tools + + +# Technical Architecture +## Interactive CLI Implementation +- Use existing Cobra framework with additional interactive libraries +- Integrate a prompt library like github.com/AlecAivazis/survey/v2 for interactive inputs +- Enhance existing color output using github.com/fatih/color +- Add spinner/progress libraries like github.com/briandowns/spinner +- Maintain backward compatibility with existing flag-based usage + +## Environment Variable System +- Implement in the root command's initialization phase +- Check for GOFI_* variables before applying default values +- Priority order: CLI flags > Environment variables > Default values +- Add validation for environment variable values +- Document all supported environment variables + +# Development Roadmap +## Phase 1: Environment Variable Support +- Add environment variable detection in cmd/gofi/cmd/root.go +- Implement GOFI_OUTPUT_DIR support +- Implement GOFI_QUALITY support with validation (1, 3, or 9) +- Implement GOFI_LOG_LEVEL support +- Update help text to document environment variables +- Add tests for environment variable functionality + +## Phase 2: Interactive CLI Enhancements +- Add interactive prompts when required flags are missing +- Implement colored output for all message types +- Add progress spinners for API calls and downloads +- Create interactive menu for quality selection +- Add confirmation prompts for batch operations +- Enhance error messages with helpful suggestions + +## Phase 3: Polish and Documentation +- Ensure consistent visual design across all prompts +- Add ASCII art banner on startup +- Update README with environment variable documentation +- Add examples of interactive usage +- Create demo GIF/video for README + +# Logical Dependency Chain +1. Environment variable support must be implemented first as it affects initialization +2. Basic colored output can be added incrementally to existing messages +3. Interactive prompts should be added after environment variables to ensure proper defaults +4. Progress indicators can be integrated with existing download logic +5. Final polish and documentation after core features are working + +# Risks and Mitigations +## Technical Challenges +- Risk: Interactive prompts might break automation scripts +- Mitigation: Ensure all interactive features are skipped when flags are provided or when not in TTY + +## Compatibility Concerns +- Risk: New dependencies might increase binary size +- Mitigation: Choose lightweight libraries and ensure they're necessary + +## User Experience +- Risk: Too many prompts might annoy power users +- Mitigation: Provide --non-interactive flag and respect environment variables + +# Appendix +## Reference Implementation +The claude-task-master CLI (https://raw.githubusercontent.com/eyaltoledano/claude-task-master/refs/heads/main/index.js) provides excellent examples of: +- Clear visual hierarchy with box drawing +- Consistent color usage +- Interactive menus with arrow key navigation +- Progress indicators for long operations +- Professional terminal UI design + +## Environment Variable Naming Convention +Following Go conventions: +- GOFI_OUTPUT_DIR (maps to -o, --output flag) +- GOFI_QUALITY (maps to -q, --quality flag) +- GOFI_LOG_LEVEL (maps to -l, --log-level flag) + \ No newline at end of file diff --git a/types/config.go b/types/config.go new file mode 100644 index 0000000..e524318 --- /dev/null +++ b/types/config.go @@ -0,0 +1,52 @@ +package types + +// Config represents the application configuration +type Config struct { + Concurrency int `json:"concurrency"` + SaveLayout struct { + Track string `json:"track"` + Album string `json:"album"` + Artist string `json:"artist"` + Playlist string `json:"playlist"` + } `json:"saveLayout"` + Playlist struct { + ResolveFullPath bool `json:"resolveFullPath"` + } `json:"playlist"` + TrackNumber bool `json:"trackNumber"` + FallbackTrack bool `json:"fallbackTrack"` + FallbackQuality bool `json:"fallbackQuality"` + CoverSize struct { + Q128 int `json:"128"` + Q320 int `json:"320"` + Flac int `json:"flac"` + } `json:"coverSize"` + Cookies struct { + ARL string `json:"arl"` + } `json:"cookies"` +} + +// DefaultConfig returns a default configuration +func DefaultConfig() Config { + config := Config{ + Concurrency: 1, + TrackNumber: false, + FallbackTrack: true, + FallbackQuality: false, + } + + // Default save layouts + config.SaveLayout.Track = "./downloads/Tracks/{ART_NAME}/{ART_NAME} - {SNG_TITLE}" + config.SaveLayout.Album = "./downloads/Albums/{ART_NAME}/{ALB_TITLE}/{TRACK_NUMBER} - {SNG_TITLE}" + config.SaveLayout.Artist = "./downloads/Artists/{ART_NAME}/{SNG_TITLE}" + config.SaveLayout.Playlist = "./downloads/Playlists/{TITLE}/{ART_NAME} - {SNG_TITLE}" + + // Default cover sizes + config.CoverSize.Q128 = 500 + config.CoverSize.Q320 = 500 + config.CoverSize.Flac = 1000 + + // Default playlist settings + config.Playlist.ResolveFullPath = false + + return config +} \ No newline at end of file