upload to github
This commit is contained in:
commit
ae9ca74ac6
23 changed files with 2133 additions and 0 deletions
30
.dockerignore
Normal file
30
.dockerignore
Normal 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
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
bin/
|
||||
release/
|
||||
go.sum
|
||||
57
Dockerfile
Normal file
57
Dockerfile
Normal 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
21
LICENSE.txt
Normal 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
166
Makefile
Normal 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
160
README.md
Normal 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
24
beat-harvester.service
Normal 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
21
docker-compose.yaml
Normal 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
8
go.mod
Normal 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
85
main.go
Normal 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
BIN
screen1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
90
src/broadcast/broadcast.go
Normal file
90
src/broadcast/broadcast.go
Normal 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
57
src/config/config.go
Normal 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
289
src/handlers/audio.go
Normal 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
90
src/handlers/common.go
Normal 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
205
src/handlers/handlers.go
Normal 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
133
src/handlers/video.go
Normal 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
26
src/models/models.go
Normal 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
BIN
src/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
97
src/static/index.html
Normal file
97
src/static/index.html
Normal 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
228
src/static/main.js
Normal 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
243
src/static/styles.css
Normal 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
100
src/utils/utils.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue