commit ae9ca74ac66c3ec4401a49a48286f9a961c2b322 Author: jrosh Date: Sat Jun 7 16:14:00 2025 +0200 upload to github diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a1957e4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ +# Ignore build artifacts +bin/ +release/ +downloads/ +temp/ +logs/ + +# Ignore version control +.git/ +.gitignore + +# Ignore documentation +*.md +LICENSE + +# Ignore IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Ignore OS files +.DS_Store +Thumbs.db + +# Ignore development files +Makefile +docker-compose.yml +.dockerignore \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab36831 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bin/ +release/ +go.sum \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..df96ad5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +# Build stage +FROM golang:1.21-alpine AS builder + +WORKDIR /app + +# Install build dependencies +RUN apk add --no-cache git + +# Copy go mod files +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o beat-harvester . + +# Runtime stage +FROM alpine:latest + +# Install runtime dependencies +RUN apk add --no-cache \ + python3 \ + py3-pip \ + ffmpeg \ + libwebp-tools \ + ca-certificates \ + tzdata + +# Install yt-dlp +RUN pip3 install --no-cache-dir yt-dlp + +# Create app user +RUN addgroup -g 1001 -S appgroup && \ + adduser -u 1001 -S appuser -G appgroup + +# Create directories +RUN mkdir -p /app/downloads /app/temp && \ + chown -R appuser:appgroup /app + +# Copy binary from builder +COPY --from=builder /app/beat-harvester /app/ + +# Switch to non-root user +USER appuser + +WORKDIR /app + +# Expose port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1 + +CMD ["./beat-harvester"] \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..91f2b41 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 jroshthen1 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3f3685e --- /dev/null +++ b/Makefile @@ -0,0 +1,166 @@ +# Makefile for Beat Harvester + +# Variables +APP_NAME := beat-harvester +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") +BUILD_TIME := $(shell date -u '+%Y-%m-%d_%H:%M:%S') + +# Build flags +LDFLAGS := -X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME) -s -w + +# Default target +.PHONY: all +all: build + +# Build the application +.PHONY: build +build: init + @echo "๐Ÿ”จ Building $(APP_NAME)..." + @mkdir -p bin + go build -ldflags "$(LDFLAGS)" -o bin/$(APP_NAME) . + +# Development server +.PHONY: dev +dev: + @echo "๐Ÿš€ Starting development server..." + go run . + +# Initialize Go module and dependencies +.PHONY: init +init: + @if [ ! -f go.mod ]; then \ + echo "Creating go.mod..."; \ + go mod init beat-harvester; \ + fi + @echo "๐Ÿ“ฆ Installing dependencies..." + go get github.com/gorilla/mux@v1.8.1 + go get github.com/gorilla/websocket@v1.5.1 + go mod tidy + +# Install dependencies +.PHONY: deps +deps: + @echo "๐Ÿ“ฆ Installing dependencies..." + go mod download + go mod verify + +# Clean build artifacts +.PHONY: clean +clean: + @echo "๐Ÿงน Cleaning..." + rm -rf bin/ release/ + go clean + +# Run tests +.PHONY: test +test: + @echo "Running tests..." + go test -v ./... + +# Format code +.PHONY: fmt +fmt: + @echo "Formatting code..." + go fmt ./... + +# Build for multiple platforms +.PHONY: build-all +build-all: + @echo "Building for all platforms..." + @mkdir -p bin + GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/$(APP_NAME)-linux-amd64 . + GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/$(APP_NAME)-darwin-amd64 . + GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/$(APP_NAME)-darwin-arm64 . + GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/$(APP_NAME)-windows-amd64.exe . + +# Create release packages +.PHONY: package +package: build-all + @echo "๐Ÿ“ฆ Creating release packages..." + @mkdir -p release + tar -czf release/$(APP_NAME)-$(VERSION)-linux-amd64.tar.gz -C bin $(APP_NAME)-linux-amd64 + tar -czf release/$(APP_NAME)-$(VERSION)-darwin-amd64.tar.gz -C bin $(APP_NAME)-darwin-amd64 + tar -czf release/$(APP_NAME)-$(VERSION)-darwin-arm64.tar.gz -C bin $(APP_NAME)-darwin-arm64 + cd bin && zip ../release/$(APP_NAME)-$(VERSION)-windows-amd64.zip $(APP_NAME)-windows-amd64.exe + +# Docker commands +.PHONY: docker-build +docker-build: + @echo "๐Ÿณ Building Docker image..." + docker build -t $(APP_NAME):$(VERSION) . + +.PHONY: docker-run +docker-run: + @echo "๐Ÿณ Running Docker container..." + docker-compose up -d + +.PHONY: docker-stop +docker-stop: + @echo "Stopping Docker container..." + docker-compose down + +.PHONY: docker-logs +docker-logs: + docker-compose logs -f + +# Install system dependencies +.PHONY: install-deps-ubuntu +install-deps-ubuntu: + @echo "Installing system dependencies for Ubuntu/Debian..." + sudo apt update + sudo apt install -y python3 python3-pip ffmpeg webp + pip3 install --user yt-dlp + +.PHONY: install-deps-macos +install-deps-macos: + @echo "Installing system dependencies for macOS..." + brew install python3 ffmpeg webp + pip3 install yt-dlp + +# Check dependencies +.PHONY: check-deps +check-deps: + @echo "Checking system dependencies..." + @command -v yt-dlp >/dev/null 2>&1 || { echo "โŒ yt-dlp not found"; exit 1; } + @command -v ffmpeg >/dev/null 2>&1 || { echo "โŒ ffmpeg not found"; exit 1; } + @command -v ffprobe >/dev/null 2>&1 || { echo "โŒ ffprobe not found"; exit 1; } + @command -v dwebp >/dev/null 2>&1 || echo "โš ๏ธ dwebp not found (thumbnail cropping disabled)" + @echo "โœ… All required dependencies found" + +# Run the application +.PHONY: run +run: build check-deps + @echo "๐ŸŽต Starting Beat Harvester..." + ./bin/$(APP_NAME) + +# Show help +.PHONY: help +help: + @echo "Beat Harvester Build System" + @echo "==========================" + @echo "" + @echo "Available targets:" + @echo " build - Build the application" + @echo " build-all - Build for all platforms" + @echo " package - Create release packages" + @echo " dev - Start development server" + @echo " run - Build and run the application" + @echo " test - Run tests" + @echo " fmt - Format code" + @echo " clean - Clean build artifacts" + @echo "" + @echo "Docker commands:" + @echo " docker-build - Build Docker image" + @echo " docker-run - Run with docker-compose" + @echo " docker-stop - Stop docker-compose" + @echo " docker-logs - View container logs" + @echo "" + @echo "Dependencies:" + @echo " init - Initialize Go module" + @echo " deps - Install Go dependencies" + @echo " install-deps-* - Install system dependencies" + @echo " check-deps - Check system dependencies" + @echo "" + @echo "Environment variables:" + @echo " PORT - Server port (default: 3000)" + @echo " OUTPUT_PATH - Default music output path" \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7b4be20 --- /dev/null +++ b/README.md @@ -0,0 +1,160 @@ +# Beat harvester + +(another ytdlp gui) + +A music downloader app with a modern web interface. Built with Go for maximum performance and easy deployment. + +## Features + +- **Modern Web UI** - Clean, responsive interface with real-time updates +- **High-Quality Downloads** - Up to 320kbps - limited by origin, with automatic metadata embedding +- **Smart Thumbnails** - Auto-crop and embed album artwork +- **Mobile-Friendly** - Responsive design for all devices +- **Efficiency:** - Idle memory usage: ~5MB + +Screenshot: + +Screenshot of UI + +## Quick Start + +### 1. Build from Source + +```bash +# Clone repository +git clone https://github.com/JRoshthen1/beat-harvester.git +cd beat-harvester + +# Install system dependencies +make install-deps-ubuntu # Ubuntu/Debian +# or +make install-deps-centos # CentOS/RHEL +# or +make install-deps-macos # macOS + +# Build and run +make build +make run +``` + +### 2. Access the Web Interface + +Open your browser to `http://localhost:3000` + +## Prerequisites + +### System Dependencies + +The application requires these external tools: + +- **yt-dlp** - YouTube download tool +- **ffmpeg** - Audio processing +- **ffprobe** - Metadata extraction +- **dwebp** (optional) - Thumbnail processing + +## Building + +### Development Build + +```bash +# Install Go dependencies +make deps + +# Build for current platform +make build + +# Run with hot reload (if you have air installed) +make dev +``` + +### Production Builds + +```bash +# Build for all platforms +make build-all + +# Build for specific platforms +make build-linux # Linux (amd64 + arm64) +make build-darwin # macOS (amd64 + arm64) +make build-windows # Windows (amd64) + +# Create release packages +make package +``` + +## Deployment Options + +### 1. Standalone Server + +```bash +# Run directly +./beat-harvester + +# With custom configuration +PORT=8080 OUTPUT_PATH=/music ./beat-harvester + +# Background process +nohup ./beat-harvester > server.log 2>&1 & + +# Systemd +# see ./beat-harvester.service +``` + +## Configuration + +### Environment Variables + +| Variable | Default | Description +|---------------|---------------|-------------------------------- +| `PORT` | `3000` | Server port +| `OUTPUT_PATH` | `$HOME/Music` | Default music output directory + +## API Reference + +### Endpoints + +| Method | Endpoint | Description +|--------|---------------|---------------------- +| POST | /api/download | # Start a download +| GET | /api/health | # Health check +| WS | /ws | # WebSocket logs +| GET | / | # Frontend interface + +### Download Request + +```json +{ + "albumName": "My Album", + "ytLink": "https://youtube.com/watch?v=...", + "audioQuality": "0", + "audioFormat": "mp3", + "embedThumbnail": true, + "cropThumbnail": true, + "outputPath": "/custom/path" +} +``` + +### Download Response + +```json +{ + "success": true, + "message": "Operations complete!", + "fileName": "song.mp3", + "filePath": "/path/to/song.mp3", + "albumName": "My Album", + "originalTitle": "Original Song Title", + "audioQuality": "320kbps", + "audioFormat": "MP3" +} +``` + +## Acknowledgments + +- **yt-dlp** - Powerful YouTube downloader +- **FFmpeg** - Audio processing toolkit +- **Gorilla** - Go web toolkit + +--- + +**Note**: This tool is for downloading content you have the right to download. \ No newline at end of file diff --git a/beat-harvester.service b/beat-harvester.service new file mode 100644 index 0000000..cb1f27b --- /dev/null +++ b/beat-harvester.service @@ -0,0 +1,24 @@ +[Unit] +Description=Beat Harvester, minimal self-hosted music downloader +After=network.target + +[Service] +Type=simple +User=musicdl +Group=musicdl +WorkingDirectory=/opt/beat-harvester +ExecStart=/opt/beat-harvester/beat-harvester +Restart=always +RestartSec=10 +Environment=PORT=3000 +Environment=OUTPUT_PATH=/home/user/Music + +# Security settings +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +# ProtectHome=true +ReadWritePaths=/home/user/Music + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..d93090e --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,21 @@ +version: '3.8' + +services: + beat-harvester: + build: . + container_name: beat-harvester + ports: + - "3000:3000" + environment: + - PORT=3000 + - OUTPUT_PATH=/app/downloads + volumes: + - ./downloads:/app/downloads + - ./temp:/app/temp + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3d991d4 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module beat-harvester + +go 1.16 + +require ( + github.com/gorilla/mux v1.8.1 + github.com/gorilla/websocket v1.5.1 +) diff --git a/main.go b/main.go new file mode 100644 index 0000000..7bc59b2 --- /dev/null +++ b/main.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "embed" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gorilla/mux" + "beat-harvester/src/config" + "beat-harvester/src/handlers" + "beat-harvester/src/utils" +) + +//go:embed src/static/* +var staticFS embed.FS + +func main() { + // Initialize configuration + cfg := config.Load() + + // Check dependencies + if err := utils.CheckDependencies(); err != nil { + log.Fatalf("Missing dependencies: %v", err) + } + + // Setup router + r := setupRoutes(cfg) + + // Create server + srv := &http.Server{ + Addr: ":" + cfg.Port, + Handler: r, + } + + // Start graceful shutdown handler + go handleGracefulShutdown(srv) + + log.Printf("Music Downloader Server starting on port %s", cfg.Port) + log.Printf("Default output path: %s", cfg.DefaultOutputPath) + log.Printf("Web interface: http://localhost:%s", cfg.Port) + + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Server failed to start: %v", err) + } +} + +func setupRoutes(cfg *config.Config) *mux.Router { + r := mux.NewRouter() + + // Initialize handlers with config + h := handlers.New(cfg, staticFS) + + // API routes + api := r.PathPrefix("/api").Subrouter() + api.HandleFunc("/download", h.Download).Methods("POST") + api.HandleFunc("/health", h.Health).Methods("GET") + + // WebSocket route + r.HandleFunc("/ws", h.WebSocket) + + // Serve static files + r.PathPrefix("/").Handler(h.Static()) + + return r +} + +func handleGracefulShutdown(srv *http.Server) { + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + log.Println("Shutting down server...") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + log.Fatalf("Server shutdown failed: %v", err) + } + log.Println("Server stopped") +} \ No newline at end of file diff --git a/screen1.png b/screen1.png new file mode 100644 index 0000000..4927c47 Binary files /dev/null and b/screen1.png differ diff --git a/src/broadcast/broadcast.go b/src/broadcast/broadcast.go new file mode 100644 index 0000000..f169acf --- /dev/null +++ b/src/broadcast/broadcast.go @@ -0,0 +1,90 @@ +package broadcast + +import ( + "log" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +var ( + // connections holds all active WebSocket connections + connections = make(map[*websocket.Conn]bool) + mutex sync.RWMutex +) + +// Log sends a log message to all connected WebSocket clients +func Log(message string) { + mutex.RLock() + + logMessage := map[string]interface{}{ + "type": "log", + "message": message, + "timestamp": time.Now().Format("15:04:05"), + } + + // Collect broken connections to remove later + var brokenConnections []*websocket.Conn + + for conn := range connections { + if err := conn.WriteJSON(logMessage); err != nil { + // Connection is broken, mark for removal + brokenConnections = append(brokenConnections, conn) + } + } + + mutex.RUnlock() + + // Remove broken connections (need write lock) + if len(brokenConnections) > 0 { + mutex.Lock() + for _, conn := range brokenConnections { + delete(connections, conn) + conn.Close() + } + mutex.Unlock() + } + + // Also log to console for debugging + log.Printf("[BROADCAST] %s", message) +} + +// AddConnection registers a new WebSocket connection +func AddConnection(conn *websocket.Conn) { + mutex.Lock() + defer mutex.Unlock() + connections[conn] = true + log.Println("WebSocket client connected") + + // Only send welcome message if this is the first connection + connectionCount := len(connections) + if connectionCount == 1 { + // Send welcome message after adding connection + go func() { + time.Sleep(100 * time.Millisecond) // Small delay to ensure connection is ready + Log("Client connected to logs") + }() + } else { + // For reconnections, just send a brief reconnection notice + go func() { + time.Sleep(100 * time.Millisecond) + Log("๐Ÿ”„ Reconnected to logs") + }() + } +} + +// RemoveConnection unregisters a WebSocket connection +func RemoveConnection(conn *websocket.Conn) { + mutex.Lock() + defer mutex.Unlock() + delete(connections, conn) + log.Printf("WebSocket client disconnected") +} + +// GetConnectionCount returns the number of active connections +func GetConnectionCount() int { + mutex.RLock() + defer mutex.RUnlock() + return len(connections) +} \ No newline at end of file diff --git a/src/config/config.go b/src/config/config.go new file mode 100644 index 0000000..c62cff4 --- /dev/null +++ b/src/config/config.go @@ -0,0 +1,57 @@ +package config + +import ( + "os" + "path/filepath" +) + +// Config holds application configuration +type Config struct { + Port string + DefaultOutputPath string + TempDir string +} + +// Constants for supported formats and quality mappings +var ( + AudioFormats = []string{"mp3", "flac", "m4a", "wav"} + VideoFormats = []string{"mp4", "webm", "mkv"} + + AudioQuality = map[string]string{ + "0": "320kbps", + "2": "256kbps", + "5": "128kbps", + "9": "64kbps", + } + + VideoQuality = map[string]string{ + "1080p": "bestvideo[height<=1080]+bestaudio/best[height<=1080]", + "720p": "bestvideo[height<=720]+bestaudio/best[height<=720]", + "480p": "bestvideo[height<=480]+bestaudio/best[height<=480]", + "worst": "worst", + } +) + +// Load creates and returns application configuration +func Load() *Config { + cfg := &Config{ + Port: "3000", + TempDir: "temp", + } + + // Load from environment + if port := os.Getenv("PORT"); port != "" { + cfg.Port = port + } + + if outputPath := os.Getenv("OUTPUT_PATH"); outputPath != "" { + cfg.DefaultOutputPath = outputPath + } else { + // Set default to user home directory + Music + if homeDir, err := os.UserHomeDir(); err == nil { + cfg.DefaultOutputPath = filepath.Join(homeDir, "Music") + } + } + + return cfg +} \ No newline at end of file diff --git a/src/handlers/audio.go b/src/handlers/audio.go new file mode 100644 index 0000000..063b943 --- /dev/null +++ b/src/handlers/audio.go @@ -0,0 +1,289 @@ +package handlers + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "beat-harvester/src/config" + "beat-harvester/src/models" + "beat-harvester/src/utils" + "beat-harvester/src/broadcast" +) + +// processAudioDownload handles the complete audio download workflow +func (h *Handler) processAudioDownload(req models.DownloadRequest) models.DownloadResponse { + broadcast.Log("๐ŸŽต Starting audio download process...") + + // Extract or use provided album name + albumName, err := h.getAlbumName(req) + if err != nil { + broadcast.Log(fmt.Sprintf("โŒ Failed to extract video info: %v", err)) + return models.DownloadResponse{ + Success: false, + Error: fmt.Sprintf("Failed to extract video info: %v", err), + } + } + + broadcast.Log(fmt.Sprintf("๐Ÿ“ Using album name: %s", albumName)) + + // Setup paths + paths := h.setupDownloadPaths(albumName, req.OutputPath) + defer func() { + broadcast.Log("๐Ÿงน Cleaning up temporary files...") + os.RemoveAll(paths.TempDir) + }() + + broadcast.Log(fmt.Sprintf("๐Ÿ“ Created directories: %s", paths.AlbumDir)) + broadcast.Log(fmt.Sprintf("๐ŸŽต Starting audio download: %s", req.YtLink)) + + // Download audio with yt-dlp + if err := h.downloadAudio(req, paths.TempDir); err != nil { + broadcast.Log(fmt.Sprintf("โŒ Audio download failed: %v", err)) + return models.DownloadResponse{ + Success: false, + Error: fmt.Sprintf("Audio download failed: %v", err), + } + } + + // Process cover art if requested + coverArtPath := "" + if req.EmbedThumbnail { + coverArtPath = h.processCoverArt(paths.TempDir, req.CropThumbnail) + } + + // Get original title and setup final file paths + tempAudioPath := filepath.Join(paths.TempDir, "temp_audio.mp3") + originalTitle := h.extractAudioTitle(tempAudioPath) + if originalTitle == "" { + originalTitle = albumName + } + + broadcast.Log(fmt.Sprintf("๐Ÿ“ Original title: %s", originalTitle)) + + finalFileName := utils.SanitizeFilename(originalTitle) + outputFile := filepath.Join(paths.TempDir, "output."+req.AudioFormat) + finalFile := filepath.Join(paths.AlbumDir, finalFileName+"."+req.AudioFormat) + + // Process final audio file with metadata and cover art + if err := h.processAudioMetadata(tempAudioPath, coverArtPath, outputFile, albumName); err != nil { + broadcast.Log(fmt.Sprintf("โŒ Audio processing failed: %v", err)) + return models.DownloadResponse{ + Success: false, + Error: fmt.Sprintf("Audio processing failed: %v", err), + } + } + + // Move to final location + broadcast.Log("๐Ÿ“ Moving file to final location...") + if err := os.Rename(outputFile, finalFile); err != nil { + broadcast.Log(fmt.Sprintf("โŒ Failed to move final file: %v", err)) + return models.DownloadResponse{ + Success: false, + Error: fmt.Sprintf("Failed to move final file: %v", err), + } + } + + return models.DownloadResponse{ + Success: true, + Message: "Audio download completed successfully!", + FileName: finalFileName + "." + req.AudioFormat, + FilePath: finalFile, + AlbumName: albumName, + OriginalTitle: originalTitle, + AudioQuality: config.AudioQuality[req.AudioQuality], + AudioFormat: strings.ToUpper(req.AudioFormat), + } +} + +// downloadAudio downloads audio using yt-dlp +func (h *Handler) downloadAudio(req models.DownloadRequest, tempDir string) error { + broadcast.Log("๐ŸŽต Downloading audio with yt-dlp...") + + tempAudioPath := filepath.Join(tempDir, "temp_audio.mp3") + args := []string{ + "-f", "ba", + "-x", + "--audio-format", "mp3", + "--audio-quality", req.AudioQuality, + "--embed-metadata", + "-o", tempAudioPath, + } + + // Add thumbnail download for cover art + if req.EmbedThumbnail { + broadcast.Log("๐Ÿ–ผ๏ธ Adding thumbnail download...") + insertPoint := len(args) - 2 + args = append(args[:insertPoint], append([]string{"--write-thumbnail"}, args[insertPoint:]...)...) + } + + args = append(args, req.YtLink) + + broadcast.Log(fmt.Sprintf("๐Ÿ”ง Running: yt-dlp %s", strings.Join(args, " "))) + + cmd := exec.Command("yt-dlp", args...) + cmd.Dir = tempDir + + // Capture both stdout and stderr + output, err := cmd.CombinedOutput() + if err != nil { + broadcast.Log(fmt.Sprintf("โŒ yt-dlp failed: %v", err)) + if len(output) > 0 { + broadcast.Log(fmt.Sprintf("โŒ yt-dlp output: %s", string(output))) + } + return err + } + + if len(output) > 0 { + broadcast.Log(fmt.Sprintf("โœ… yt-dlp output: %s", string(output))) + } + + // Verify the file was created + if !utils.FileExists(tempAudioPath) { + broadcast.Log(fmt.Sprintf("โŒ Expected audio file not found: %s", tempAudioPath)) + return fmt.Errorf("audio file was not created at expected path: %s", tempAudioPath) + } + + broadcast.Log("โœ… Audio download completed successfully") + return nil +} + +// processCoverArt converts and processes cover art from WebP to JPEG +func (h *Handler) processCoverArt(tempDir string, cropThumbnail bool) string { + webpFile := utils.FindWebPFile(tempDir) + if webpFile == "" { + broadcast.Log("โš ๏ธ No WebP thumbnail found") + return "" + } + + broadcast.Log(fmt.Sprintf("๐Ÿ–ผ๏ธ Found thumbnail: %s", webpFile)) + broadcast.Log("๐Ÿ–ผ๏ธ Converting cover art...") + + webpPath := filepath.Join(tempDir, webpFile) + coverArtPath := filepath.Join(tempDir, "cover.jpg") + + var cmd *exec.Cmd + if _, err := exec.LookPath("dwebp"); err == nil { + // Use dwebp if available + broadcast.Log("๐Ÿ”ง Using dwebp for conversion") + if cropThumbnail { + cmd = exec.Command("dwebp", + "-crop", "280", "0", "720", "720", + "-resize", "512", "512", + webpPath, "-o", coverArtPath) + } else { + cmd = exec.Command("dwebp", webpPath, "-o", coverArtPath) + } + } else { + // Fallback to ffmpeg + broadcast.Log("๐Ÿ”ง Using ffmpeg for conversion") + if cropThumbnail { + cmd = exec.Command("ffmpeg", "-i", webpPath, + "-vf", "crop=720:720:280:0,scale=512:512", + "-y", coverArtPath) + } else { + cmd = exec.Command("ffmpeg", "-i", webpPath, "-y", coverArtPath) + } + } + + // Capture output for debugging + output, err := cmd.CombinedOutput() + if err != nil { + broadcast.Log(fmt.Sprintf("โš ๏ธ Failed to convert cover art: %v", err)) + if len(output) > 0 { + broadcast.Log(fmt.Sprintf("โš ๏ธ Conversion output: %s", string(output))) + } + return "" + } + + if utils.FileExists(coverArtPath) { + broadcast.Log("โœ… Cover art converted successfully") + return coverArtPath + } + + broadcast.Log("โš ๏ธ Cover art file not created") + return "" +} + +// processAudioMetadata embeds cover art and metadata into the audio file +func (h *Handler) processAudioMetadata(inputPath, coverArtPath, outputPath, albumName string) error { + broadcast.Log("๐ŸŽ›๏ธ Processing final audio file with metadata...") + + args := []string{"-i", inputPath} + + // Add cover art input if available + if coverArtPath != "" && utils.FileExists(coverArtPath) { + broadcast.Log("๐Ÿ–ผ๏ธ Adding cover art to audio file") + args = append(args, "-i", coverArtPath) + } + + // Add metadata + args = append(args, + "-metadata", "album="+albumName, + "-metadata", "description=", + "-metadata", "synopsis=") + + // Configure streams and encoding + if coverArtPath != "" && utils.FileExists(coverArtPath) { + args = append(args, + "-map", "0:0", // Map audio stream + "-map", "1:0", // Map cover art + "-c:a", "copy", // Copy audio without re-encoding + "-id3v2_version", "3", // Use ID3v2.3 + "-metadata:s:v", "title=Album cover", + "-metadata:s:v", "comment=Cover (front)") + } else { + args = append(args, "-c:a", "copy") + } + + args = append(args, "-y", outputPath) + + broadcast.Log(fmt.Sprintf("๐Ÿ”ง Running: ffmpeg %s", strings.Join(args, " "))) + + cmd := exec.Command("ffmpeg", args...) + output, err := cmd.CombinedOutput() + + if err != nil { + broadcast.Log(fmt.Sprintf("โŒ FFmpeg failed: %v", err)) + if len(output) > 0 { + broadcast.Log(fmt.Sprintf("โŒ FFmpeg output: %s", string(output))) + } + return err + } + + if len(output) > 0 { + broadcast.Log(fmt.Sprintf("โœ… FFmpeg output: %s", string(output))) + } + + // Verify output file was created + if !utils.FileExists(outputPath) { + broadcast.Log(fmt.Sprintf("โŒ Expected output file not found: %s", outputPath)) + return fmt.Errorf("output file was not created at expected path: %s", outputPath) + } + + broadcast.Log("โœ… Audio metadata processing completed") + return nil +} + +// extractAudioTitle extracts the title from an audio file +func (h *Handler) extractAudioTitle(audioPath string) string { + if !utils.FileExists(audioPath) { + broadcast.Log(fmt.Sprintf("โš ๏ธ Audio file not found for title extraction: %s", audioPath)) + return "" + } + + cmd := exec.Command("ffprobe", "-v", "quiet", "-show_entries", "format_tags=title", "-of", "csv=p=0", audioPath) + output, err := cmd.Output() + if err != nil { + broadcast.Log(fmt.Sprintf("โš ๏ธ Failed to extract title: %v", err)) + return "" + } + + title := strings.TrimSpace(string(output)) + if title != "" { + broadcast.Log(fmt.Sprintf("๐Ÿ“ Extracted title: %s", title)) + } + return title +} \ No newline at end of file diff --git a/src/handlers/common.go b/src/handlers/common.go new file mode 100644 index 0000000..8f24591 --- /dev/null +++ b/src/handlers/common.go @@ -0,0 +1,90 @@ +package handlers + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "beat-harvester/src/models" + "beat-harvester/src/utils" + "beat-harvester/src/broadcast" +) + +// DownloadPaths holds the directory paths for a download operation +type DownloadPaths struct { + AlbumDir string + TempDir string +} + +// getAlbumName extracts or returns the album name for the download +func (h *Handler) getAlbumName(req models.DownloadRequest) (string, error) { + if req.AlbumName != "" { + broadcast.Log(fmt.Sprintf("๐Ÿ“ Using provided album name: %s", req.AlbumName)) + return req.AlbumName, nil + } + + broadcast.Log("๐Ÿ“ Extracting video title...") + title, err := h.extractVideoInfo(req.YtLink) + if err != nil { + broadcast.Log(fmt.Sprintf("โŒ Failed to extract title: %v", err)) + return "", err + } + + broadcast.Log(fmt.Sprintf("๐Ÿ“ Extracted title: %s", title)) + return title, nil +} + +// setupDownloadPaths creates and returns the necessary directory paths +func (h *Handler) setupDownloadPaths(albumName, outputPath string) DownloadPaths { + sanitizedAlbumName := utils.SanitizeFilename(albumName) + baseOutputPath := outputPath + if baseOutputPath == "" { + baseOutputPath = h.config.DefaultOutputPath + } + + albumDir := filepath.Join(baseOutputPath, sanitizedAlbumName) + tempDir := filepath.Join(albumDir, h.config.TempDir) + + broadcast.Log(fmt.Sprintf("๐Ÿ“ Album directory: %s", albumDir)) + broadcast.Log(fmt.Sprintf("๐Ÿ“ Temp directory: %s", tempDir)) + + // Create directories + if err := os.MkdirAll(tempDir, 0755); err != nil { + broadcast.Log(fmt.Sprintf("โŒ Failed to create directories: %v", err)) + } else { + broadcast.Log("โœ… Directories created successfully") + } + + return DownloadPaths{ + AlbumDir: albumDir, + TempDir: tempDir, + } +} + +// extractVideoInfo gets video title using yt-dlp +func (h *Handler) extractVideoInfo(url string) (string, error) { + broadcast.Log("๐Ÿ” Extracting video information...") + + cmd := exec.Command("yt-dlp", "--get-title", "--no-warnings", url) + + // Capture both stdout and stderr for better debugging + output, err := cmd.CombinedOutput() + if err != nil { + broadcast.Log(fmt.Sprintf("โŒ Failed to get video title: %v", err)) + if len(output) > 0 { + broadcast.Log(fmt.Sprintf("โŒ yt-dlp output: %s", string(output))) + } + return "", err + } + + title := strings.TrimSpace(string(output)) + if title == "" { + broadcast.Log("โš ๏ธ Empty title received, using fallback") + return "Unknown", nil + } + + broadcast.Log(fmt.Sprintf("โœ… Video title extracted: %s", title)) + return title, nil +} \ No newline at end of file diff --git a/src/handlers/handlers.go b/src/handlers/handlers.go new file mode 100644 index 0000000..35d2494 --- /dev/null +++ b/src/handlers/handlers.go @@ -0,0 +1,205 @@ +package handlers + +import ( + "embed" + "encoding/json" + "fmt" + "io/fs" + "log" + "net/http" + "time" + + "github.com/gorilla/websocket" + "beat-harvester/src/config" + "beat-harvester/src/models" + "beat-harvester/src/utils" + "beat-harvester/src/broadcast" +) + +// Handler contains all HTTP handlers and their dependencies +type Handler struct { + config *config.Config + staticFS embed.FS + upgrader websocket.Upgrader +} + +// New creates a new handler instance +func New(cfg *config.Config, staticFS embed.FS) *Handler { + return &Handler{ + config: cfg, + staticFS: staticFS, + upgrader: websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true // Allow all origins in development + }, + ReadBufferSize: 1024, + WriteBufferSize: 1024, + }, + } +} + +// Health returns the application health status +func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { + response := map[string]interface{}{ + "status": "OK", + "timestamp": time.Now().Format(time.RFC3339), + "config": map[string]interface{}{ + "defaultOutputPath": h.config.DefaultOutputPath, + "audioFormats": config.AudioFormats, + "videoFormats": config.VideoFormats, + }, + "connections": broadcast.GetConnectionCount(), + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// WebSocket handles WebSocket connections for real-time logging +func (h *Handler) WebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := h.upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("WebSocket upgrade failed: %v", err) + return + } + defer conn.Close() + + // Register connection + broadcast.AddConnection(conn) + defer broadcast.RemoveConnection(conn) + + // Set up ping/pong to keep connection alive + conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + conn.SetPongHandler(func(string) error { + conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + return nil + }) + + // Start ping ticker + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + // Handle incoming messages and keep connection alive + done := make(chan struct{}) + + go func() { + defer close(done) + for { + _, _, err := conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("WebSocket error: %v", err) + } + break + } + } + }() + + for { + select { + case <-done: + return + case <-ticker.C: + conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +// Download handles download requests and starts the download process +func (h *Handler) Download(w http.ResponseWriter, r *http.Request) { + var req models.DownloadRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("Failed to decode request: %v", err) + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Validate request + if err := h.validateDownloadRequest(&req); err != nil { + log.Printf("Request validation failed: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + log.Printf("Starting download for: %s", req.YtLink) + broadcast.Log(fmt.Sprintf("๐Ÿš€ Download request received: %s", req.YtLink)) + + // Process download asynchronously + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("Download panic recovered: %v", r) + broadcast.Log(fmt.Sprintf("โŒ Download failed with panic: %v", r)) + } + }() + + var response models.DownloadResponse + + if req.DownloadVideo { + broadcast.Log("๐Ÿ“น Processing as video download") + response = h.processVideoDownload(req) + } else { + broadcast.Log("๐ŸŽต Processing as audio download") + response = h.processAudioDownload(req) + } + + // Broadcast completion status + if response.Success { + broadcast.Log(fmt.Sprintf("โœ… Download completed: %s", response.FileName)) + broadcast.Log(fmt.Sprintf("๐Ÿ“ Saved to: %s", response.FilePath)) + } else { + broadcast.Log(fmt.Sprintf("โŒ Download failed: %s", response.Error)) + } + + log.Printf("Download completed: Success=%v, Error=%s", response.Success, response.Error) + }() + + // Return immediate response + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Download started", + }) +} + +// Static serves the embedded static files +func (h *Handler) Static() http.Handler { + staticSubFS, err := fs.Sub(h.staticFS, "src/static") + if err != nil { + log.Fatal("Failed to create static sub filesystem:", err) + } + return http.FileServer(http.FS(staticSubFS)) +} + +// validateDownloadRequest validates the download request parameters +func (h *Handler) validateDownloadRequest(req *models.DownloadRequest) error { + if req.YtLink == "" { + return fmt.Errorf("YouTube URL is required") + } + + if !utils.IsValidYouTubeURL(req.YtLink) { + return fmt.Errorf("invalid YouTube URL format") + } + + // Set defaults and validate formats + if req.DownloadVideo { + if !utils.Contains(config.VideoFormats, req.AudioFormat) { + req.AudioFormat = "mp4" // Default for video + } + if req.AudioQuality == "" { + req.AudioQuality = "1080p" // Default video quality + } + } else { + if !utils.Contains(config.AudioFormats, req.AudioFormat) { + req.AudioFormat = "mp3" // Default for audio + } + if req.AudioQuality == "" { + req.AudioQuality = "0" // Default to best audio quality + } + } + + return nil +} \ No newline at end of file diff --git a/src/handlers/video.go b/src/handlers/video.go new file mode 100644 index 0000000..f810e75 --- /dev/null +++ b/src/handlers/video.go @@ -0,0 +1,133 @@ +package handlers + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "beat-harvester/src/config" + "beat-harvester/src/models" + "beat-harvester/src/utils" + "beat-harvester/src/broadcast" +) + +// processVideoDownload handles the complete video download workflow +func (h *Handler) processVideoDownload(req models.DownloadRequest) models.DownloadResponse { + // Extract or use provided album name + albumName, err := h.getAlbumName(req) + if err != nil { + return models.DownloadResponse{ + Success: false, + Error: fmt.Sprintf("Failed to extract video info: %v", err), + } + } + + // Setup paths + paths := h.setupDownloadPaths(albumName, req.OutputPath) + defer os.RemoveAll(paths.TempDir) // Cleanup + + broadcast.Log(fmt.Sprintf("๐ŸŽฌ Starting video download: %s", req.YtLink)) + + // Download video with yt-dlp + videoFile, err := h.downloadVideo(req, paths.TempDir) + if err != nil { + return models.DownloadResponse{ + Success: false, + Error: fmt.Sprintf("Video download failed: %v", err), + } + } + + // Extract title and setup final paths + videoPath := filepath.Join(paths.TempDir, videoFile) + originalTitle := h.extractVideoTitle(videoPath) + if originalTitle == "" { + originalTitle = albumName + } + + finalFileName := utils.SanitizeFilename(originalTitle) + finalFile := filepath.Join(paths.AlbumDir, finalFileName+"."+req.AudioFormat) + + // Move video to final location + if err := os.Rename(videoPath, finalFile); err != nil { + return models.DownloadResponse{ + Success: false, + Error: fmt.Sprintf("Failed to move video file: %v", err), + } + } + + return models.DownloadResponse{ + Success: true, + Message: "Video download completed successfully!", + FileName: finalFileName + "." + req.AudioFormat, + FilePath: finalFile, + AlbumName: albumName, + OriginalTitle: originalTitle, + AudioQuality: h.getVideoQualityLabel(req.AudioQuality), + AudioFormat: strings.ToUpper(req.AudioFormat), + } +} + +// downloadVideo downloads video using yt-dlp +func (h *Handler) downloadVideo(req models.DownloadRequest, tempDir string) (string, error) { + broadcast.Log("๐ŸŽฌ Downloading video...") + + tempVideoPath := filepath.Join(tempDir, "temp_video.%(ext)s") + formatSelector := h.getVideoFormatSelector(req.AudioQuality) + + args := []string{ + "-f", formatSelector, + "--embed-metadata", + "-o", tempVideoPath, + req.YtLink, + } + + cmd := exec.Command("yt-dlp", args...) + if err := cmd.Run(); err != nil { + broadcast.Log(fmt.Sprintf("โŒ yt-dlp video download failed: %v", err)) + return "", err + } + + // Find the downloaded video file + videoFile := utils.FindVideoFile(tempDir) + if videoFile == "" { + broadcast.Log("โŒ Downloaded video file not found") + return "", fmt.Errorf("downloaded video file not found") + } + + return videoFile, nil +} + +// getVideoFormatSelector returns the appropriate format selector for yt-dlp +func (h *Handler) getVideoFormatSelector(quality string) string { + if selector, exists := config.VideoQuality[quality]; exists { + return selector + } + return config.VideoQuality["1080p"] // Default to 1080p +} + +// getVideoQualityLabel returns a human-readable quality label +func (h *Handler) getVideoQualityLabel(quality string) string { + qualityLabels := map[string]string{ + "1080p": "1080p", + "720p": "720p", + "480p": "480p", + "worst": "360p or lower", + } + + if label, exists := qualityLabels[quality]; exists { + return label + } + return "Best available" +} + +// extractVideoTitle extracts the title from a video file +func (h *Handler) extractVideoTitle(videoPath string) string { + cmd := exec.Command("ffprobe", "-v", "quiet", "-show_entries", "format_tags=title", "-of", "csv=p=0", videoPath) + output, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(output)) +} \ No newline at end of file diff --git a/src/models/models.go b/src/models/models.go new file mode 100644 index 0000000..7158d32 --- /dev/null +++ b/src/models/models.go @@ -0,0 +1,26 @@ +package models + +// DownloadRequest represents the download request payload +type DownloadRequest struct { + AlbumName string `json:"albumName"` + YtLink string `json:"ytLink"` + AudioQuality string `json:"audioQuality"` + AudioFormat string `json:"audioFormat"` + EmbedThumbnail bool `json:"embedThumbnail"` + CropThumbnail bool `json:"cropThumbnail"` + OutputPath string `json:"outputPath"` + DownloadVideo bool `json:"downloadVideo"` +} + +// DownloadResponse represents the download response +type DownloadResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + FileName string `json:"fileName"` + FilePath string `json:"filePath"` + AlbumName string `json:"albumName"` + OriginalTitle string `json:"originalTitle"` + AudioQuality string `json:"audioQuality"` + AudioFormat string `json:"audioFormat"` + Error string `json:"error,omitempty"` +} \ No newline at end of file diff --git a/src/static/favicon.ico b/src/static/favicon.ico new file mode 100644 index 0000000..820b5a6 Binary files /dev/null and b/src/static/favicon.ico differ diff --git a/src/static/index.html b/src/static/index.html new file mode 100644 index 0000000..a4cf5c7 --- /dev/null +++ b/src/static/index.html @@ -0,0 +1,97 @@ + + + + + + + Beat Harvester + + + + +
+ ๐Ÿ”Œ Connecting... +
+ +
+

๐ŸŽต Beat Harvester

+ +
+
+ + +
+ +
+ + +
+ +
+
+ โ–ถ +

Advanced Options

+
+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
+
+ + +
+ +
+ +
+
+
+
+ + + + + \ No newline at end of file diff --git a/src/static/main.js b/src/static/main.js new file mode 100644 index 0000000..a5ff855 --- /dev/null +++ b/src/static/main.js @@ -0,0 +1,228 @@ + +let ws = null; +let isConnected = false; +let reconnectAttempts = 0; +let maxReconnectAttempts = 5; +let isPageVisible = true; + +// WebSocket connection +function connectWebSocket() { + // Don't try to connect if page is not visible + if (!isPageVisible) { + return; + } + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/ws`; + + console.log('Attempting WebSocket connection...'); + ws = new WebSocket(wsUrl); + + ws.onopen = function () { + console.log('WebSocket connected'); + isConnected = true; + reconnectAttempts = 0; + updateConnectionStatus(); + }; + + ws.onclose = function (event) { + console.log('WebSocket disconnected:', event.code, event.reason); + isConnected = false; + updateConnectionStatus(); + + // Only attempt to reconnect if the page is visible and we haven't exceeded max attempts + if (isPageVisible && reconnectAttempts < maxReconnectAttempts) { + reconnectAttempts++; + const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); // Exponential backoff, max 30s + console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts}/${maxReconnectAttempts})`); + setTimeout(connectWebSocket, delay); + } + }; + + ws.onerror = function (error) { + console.error('WebSocket error:', error); + isConnected = false; + updateConnectionStatus(); + }; + + ws.onmessage = function (event) { + try { + const data = JSON.parse(event.data); + handleWebSocketMessage(data); + } catch (e) { + console.error('Failed to parse WebSocket message:', e); + } + }; +} + +function updateConnectionStatus() { + const statusEl = document.getElementById('connectionStatus'); + if (isConnected) { + statusEl.textContent = '๐ŸŸข Connected'; + statusEl.className = 'connection-status connected'; + } else { + statusEl.textContent = '๐Ÿ”ด Disconnected'; + statusEl.className = 'connection-status disconnected'; + } +} + +function handleWebSocketMessage(data) { + if (data.type === 'log') { + addLogEntry(data.timestamp, data.message); + showLogsContainer(); + } +} + +function addLogEntry(timestamp, message) { + const logsList = document.getElementById('logsList'); + const logEntry = document.createElement('div'); + logEntry.className = 'log-entry'; + logEntry.innerHTML = `[${timestamp}]${message}`; + logsList.appendChild(logEntry); + + // Auto-scroll to bottom + const logsContainer = document.getElementById('logsContainer'); + logsContainer.scrollTop = logsContainer.scrollHeight; +} + +function showLogsContainer() { + document.getElementById('logsContainer').classList.add('show'); +} + +function showStatus(message, type = 'info') { + const statusDiv = document.getElementById('statusMessage'); + statusDiv.textContent = message; + statusDiv.className = `status ${type}`; + statusDiv.style.display = 'block'; +} + +function toggleAdvanced() { + const content = document.getElementById('advancedContent'); + const icon = document.querySelector('.toggle-icon'); + + content.classList.toggle('expanded'); + icon.classList.toggle('expanded'); +} + +// Handle video/audio toggle +document.getElementById('downloadVideo').addEventListener('change', function () { + const isVideo = this.checked; + const formatSelect = document.getElementById('audioFormat'); + const qualitySelect = document.getElementById('audioQuality'); + const qualityLabel = document.getElementById('qualityLabel'); + const downloadBtn = document.getElementById('downloadBtn'); + const audioOnlyOptions = document.getElementById('audioOnlyOptions'); + + if (isVideo) { + // Video mode + formatSelect.innerHTML = ` + + + + `; + qualitySelect.innerHTML = ` + + + + + `; + qualityLabel.textContent = 'Video Quality'; + downloadBtn.textContent = '๐ŸŽฌ Download Video'; + audioOnlyOptions.style.display = 'none'; + + } else { + // Audio mode + formatSelect.innerHTML = ` + + + + + `; + qualitySelect.innerHTML = ` + + + + + `; + qualityLabel.textContent = 'Audio Quality'; + downloadBtn.textContent = '๐ŸŽต Download Audio'; + audioOnlyOptions.style.display = 'block'; + } +}); + +document.getElementById('downloadForm').addEventListener('submit', async function (e) { + e.preventDefault(); + + const formData = new FormData(e.target); + const data = Object.fromEntries(formData.entries()); + + // Add checkbox values + data.embedThumbnail = document.getElementById('embedThumbnail').checked; + data.cropThumbnail = document.getElementById('cropThumbnail').checked; + data.downloadVideo = document.getElementById('downloadVideo').checked; + + const downloadBtn = document.getElementById('downloadBtn'); + + downloadBtn.disabled = true; + downloadBtn.textContent = 'Starting...'; + showStatus('Sending request to server...', 'info'); + + try { + const response = await fetch('/api/download', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data) + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || 'Download failed'); + } + + showStatus('Download started! Check logs below for progress.', 'success'); + + } catch (error) { + console.error('Download error:', error); + showStatus('Download failed: ' + error.message, 'error'); + } finally { + downloadBtn.disabled = false; + downloadBtn.textContent = data.downloadVideo ? '๐ŸŽฌ Download Video' : '๐ŸŽต Download Audio'; + } +}); + +// Initialize the application +document.addEventListener('DOMContentLoaded', function () { + console.log('๐ŸŽต Music Downloader - Initializing...'); + connectWebSocket(); + + // Initialize video/audio toggle + document.getElementById('downloadVideo').dispatchEvent(new Event('change')); + + console.log('โœ… Application initialized'); +}); + +// Handle page visibility changes +document.addEventListener('visibilitychange', function () { + isPageVisible = !document.hidden; + + if (isPageVisible) { + console.log('Page became visible'); + // Reset reconnection attempts when page becomes visible + reconnectAttempts = 0; + // Only reconnect if we're not connected + if (!isConnected) { + console.log('Reconnecting due to page visibility change'); + connectWebSocket(); + } + } else { + console.log('Page became hidden'); + // Optionally close the connection when page is hidden to prevent unnecessary reconnection attempts + if (ws && isConnected) { + console.log('Closing WebSocket due to page becoming hidden'); + ws.close(1000, 'Page hidden'); + } + } +}); diff --git a/src/static/styles.css b/src/static/styles.css new file mode 100644 index 0000000..a420321 --- /dev/null +++ b/src/static/styles.css @@ -0,0 +1,243 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; + padding: 20px; +} + +.container { + max-width: 800px; + margin: 0 auto; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + border-radius: 20px; + padding: 40px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1); +} + +h1 { + text-align: center; + color: #2d3748; + margin-bottom: 30px; + font-size: 2.5rem; + font-weight: 700; +} + +.form-group { + margin-bottom: 25px; +} + +label { + display: block; + margin-bottom: 8px; + font-weight: 600; + color: #4a5568; + font-size: 1.1rem; +} + +input[type="text"], +input[type="url"], +select { + width: 100%; + padding: 15px; + border: 2px solid #e2e8f0; + border-radius: 10px; + font-size: 16px; + transition: all 0.3s ease; + background: white; +} + +input[type="text"]:focus, +input[type="url"]:focus, +select:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.advanced-options { + background: #f8fafc; + border-radius: 12px; + padding: 20px; + margin: 20px 0; + border: 1px solid #e2e8f0; +} + +.advanced-header { + display: flex; + align-items: center; + cursor: pointer; + margin-bottom: 15px; +} + +.advanced-header h3 { + color: #4a5568; + margin-left: 10px; +} + +.toggle-icon { + transition: transform 0.3s ease; +} + +.toggle-icon.expanded { + transform: rotate(90deg); +} + +.advanced-content { + display: none; +} + +.advanced-content.expanded { + display: block; +} + +.row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + margin-bottom: 20px; +} + +.checkbox-group { + display: flex; + align-items: center; + margin-bottom: 15px; +} + +.checkbox-group input[type="checkbox"] { + width: auto; + margin-right: 10px; + transform: scale(1.2); +} + +.download-btn { + width: 100%; + padding: 18px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + border-radius: 12px; + font-size: 18px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + text-transform: uppercase; + letter-spacing: 1px; +} + +.download-btn:hover { + transform: translateY(-2px); + box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3); +} + +.download-btn:disabled { + background: #a0aec0; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.status { + margin-top: 20px; + padding: 15px; + border-radius: 8px; + font-weight: 500; + display: none; +} + +.status.success { + background: #c6f6d5; + color: #22543d; + border: 1px solid #9ae6b4; +} + +.status.error { + background: #fed7d7; + color: #742a2a; + border: 1px solid #fc8181; +} + +.status.info { + background: #bee3f8; + color: #2a4365; + border: 1px solid #90cdf4; +} + +.logs-container { + margin-top: 30px; + background: #2d3748; + border-radius: 12px; + padding: 20px; + max-height: 300px; + overflow-y: auto; + font-family: 'Courier New', monospace; + font-size: 14px; + line-height: 1.5; + display: none; +} + +.logs-container.show { + display: block; +} + +.log-entry { + color: #e2e8f0; + margin-bottom: 5px; +} + +.log-timestamp { + color: #a0aec0; + margin-right: 10px; +} + +.connection-status { + position: fixed; + top: 20px; + right: 20px; + padding: 10px 15px; + border-radius: 8px; + font-size: 0.9rem; + font-weight: 500; + z-index: 1000; +} + +.connection-status.connected { + background: #c6f6d5; + color: #22543d; + border: 1px solid #9ae6b4; +} + +.connection-status.disconnected { + background: #fed7d7; + color: #742a2a; + border: 1px solid #fc8181; +} + +@media (max-width: 768px) { + .container { + padding: 20px; + margin: 10px; + } + + .row { + grid-template-columns: 1fr; + } + + h1 { + font-size: 2rem; + } + + .connection-status { + position: relative; + top: auto; + right: auto; + margin-bottom: 20px; + display: inline-block; + } +} \ No newline at end of file diff --git a/src/utils/utils.go b/src/utils/utils.go new file mode 100644 index 0000000..7d704c1 --- /dev/null +++ b/src/utils/utils.go @@ -0,0 +1,100 @@ +package utils + +import ( + "fmt" + "log" + "os" + "os/exec" + "regexp" + "strings" +) + +// CheckDependencies verifies that required external tools are available +func CheckDependencies() error { + dependencies := []string{"yt-dlp", "ffmpeg", "ffprobe"} + + for _, dep := range dependencies { + if _, err := exec.LookPath(dep); err != nil { + return fmt.Errorf("missing dependency: %s", dep) + } + } + + // Check for dwebp (optional, for thumbnail processing) + if _, err := exec.LookPath("dwebp"); err != nil { + log.Println("โš ๏ธ Warning: dwebp not found, thumbnail cropping will be disabled") + } + + return nil +} + +// SanitizeFilename removes invalid characters for filenames +func SanitizeFilename(filename string) string { + re := regexp.MustCompile(`[<>:"/\\|?*]`) + return strings.TrimSpace(re.ReplaceAllString(filename, "_")) +} + +// IsValidYouTubeURL validates YouTube URL formats +func IsValidYouTubeURL(url string) bool { + patterns := []string{ + `^https?://(www\.)?youtube\.com/watch\?v=[\w-]+`, + `^https?://youtu\.be/[\w-]+`, + `^https?://(www\.)?youtube\.com/playlist\?list=[\w-]+`, + } + + for _, pattern := range patterns { + if matched, _ := regexp.MatchString(pattern, url); matched { + return true + } + } + return false +} + +// Contains checks if a slice contains a specific item +func Contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +// FindWebPFile finds a WebP file in the given directory +func FindWebPFile(dir string) string { + files, err := os.ReadDir(dir) + if err != nil { + return "" + } + + for _, file := range files { + if strings.HasSuffix(strings.ToLower(file.Name()), ".webp") { + return file.Name() + } + } + return "" +} + +// FindVideoFile finds a video file in the given directory +func FindVideoFile(dir string) string { + files, err := os.ReadDir(dir) + if err != nil { + return "" + } + + videoExtensions := []string{".mp4", ".webm", ".mkv", ".avi", ".mov"} + for _, file := range files { + fileName := strings.ToLower(file.Name()) + for _, ext := range videoExtensions { + if strings.HasSuffix(fileName, ext) && strings.Contains(fileName, "temp_video") { + return file.Name() + } + } + } + return "" +} + +// FileExists checks if a file exists +func FileExists(filename string) bool { + _, err := os.Stat(filename) + return !os.IsNotExist(err) +} \ No newline at end of file