upload to github

This commit is contained in:
jrosh 2025-06-07 16:14:00 +02:00
commit ae9ca74ac6
No known key found for this signature in database
GPG key ID: A4D68DCA6C9CCD2D
23 changed files with 2133 additions and 0 deletions

30
.dockerignore Normal file
View file

@ -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

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
bin/
release/
go.sum

57
Dockerfile Normal file
View file

@ -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"]

21
LICENSE.txt Normal file
View file

@ -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.

166
Makefile Normal file
View file

@ -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"

160
README.md Normal file
View file

@ -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:
<img src="./screen1.png" alt="Screenshot of UI" width="411px" height="446px">
## 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.

24
beat-harvester.service Normal file
View file

@ -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

21
docker-compose.yaml Normal file
View file

@ -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

8
go.mod Normal file
View file

@ -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
)

85
main.go Normal file
View file

@ -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")
}

BIN
screen1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View file

@ -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)
}

57
src/config/config.go Normal file
View file

@ -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
}

289
src/handlers/audio.go Normal file
View file

@ -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
}

90
src/handlers/common.go Normal file
View file

@ -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
}

205
src/handlers/handlers.go Normal file
View file

@ -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
}

133
src/handlers/video.go Normal file
View file

@ -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))
}

26
src/models/models.go Normal file
View file

@ -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"`
}

BIN
src/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

97
src/static/index.html Normal file
View file

@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Beat Harvester</title>
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<div class="connection-status disconnected" id="connectionStatus">
🔌 Connecting...
</div>
<div class="container">
<h1>🎵 Beat Harvester</h1>
<form id="downloadForm">
<div class="form-group">
<label for="albumName">Album Name (optional)</label>
<input type="text" id="albumName" name="albumName"
placeholder="Leave empty to use video/playlist title">
</div>
<div class="form-group">
<label for="ytLink">YouTube URL *</label>
<input type="url" id="ytLink" name="ytLink" required placeholder="https://www.youtube.com/watch?v=...">
</div>
<div class="advanced-options">
<div class="advanced-header" onclick="toggleAdvanced()">
<span class="toggle-icon"></span>
<h3>Advanced Options</h3>
</div>
<div class="advanced-content" id="advancedContent">
<div class="checkbox-group">
<input type="checkbox" id="downloadVideo" name="downloadVideo">
<label for="downloadVideo">Download as video instead of audio</label>
</div>
<div class="row">
<div class="form-group">
<label for="audioQuality" id="qualityLabel">Audio Quality</label>
<select id="audioQuality" name="audioQuality">
<option value="0">Best (320kbps)</option>
<option value="2">High (256kbps)</option>
<option value="5">Medium (128kbps)</option>
<option value="9">Low (64kbps)</option>
</select>
</div>
<div class="form-group">
<label for="audioFormat">Format</label>
<select id="audioFormat" name="audioFormat">
<option value="mp3">MP3</option>
<option value="flac">FLAC</option>
<option value="m4a">M4A</option>
<option value="wav">WAV</option>
</select>
</div>
</div>
<div class="audio-only-options" id="audioOnlyOptions">
<div class="checkbox-group">
<input type="checkbox" id="embedThumbnail" name="embedThumbnail" checked>
<label for="embedThumbnail">Embed cover art</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="cropThumbnail" name="cropThumbnail" checked>
<label for="cropThumbnail">Crop cover art to square</label>
</div>
</div>
<div class="form-group">
<label for="outputPath">Custom Output Path</label>
<input type="text" id="outputPath" name="outputPath" placeholder="Default: ~/Music">
</div>
</div>
</div>
<button type="submit" class="download-btn" id="downloadBtn">
🎵 Download Audio
</button>
</form>
<div class="status" id="statusMessage"></div>
<div class="logs-container" id="logsContainer">
<div id="logsList"></div>
</div>
</div>
<script src="./main.js"></script>
</body>
</html>

228
src/static/main.js Normal file
View file

@ -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 = `<span class="log-timestamp">[${timestamp}]</span>${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 = `
<option value="mp4">MP4</option>
<option value="webm">WebM</option>
<option value="mkv">MKV</option>
`;
qualitySelect.innerHTML = `
<option value="bestvideo[height<=1080]+bestaudio/best[height<=1080]">Best (1080p)</option>
<option value="bestvideo[height<=720]+bestaudio/best[height<=720]">High (720p)</option>
<option value="bestvideo[height<=480]+bestaudio/best[height<=480]">Medium (480p)</option>
<option value="worst">Low (360p)</option>
`;
qualityLabel.textContent = 'Video Quality';
downloadBtn.textContent = '🎬 Download Video';
audioOnlyOptions.style.display = 'none';
} else {
// Audio mode
formatSelect.innerHTML = `
<option value="mp3">MP3</option>
<option value="flac">FLAC</option>
<option value="m4a">M4A</option>
<option value="wav">WAV</option>
`;
qualitySelect.innerHTML = `
<option value="0">Best (320kbps)</option>
<option value="2">High (256kbps)</option>
<option value="5">Medium (128kbps)</option>
<option value="9">Low (64kbps)</option>
`;
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');
}
}
});

243
src/static/styles.css Normal file
View file

@ -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;
}
}

100
src/utils/utils.go Normal file
View file

@ -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)
}