feat: initialise project structure with core encoding queue, database models, and web management API
Build / Build and Push (push) Successful in 50s
Build / Build and Push (push) Successful in 50s
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
media/
|
||||
db/
|
||||
bin/
|
||||
*.mkv
|
||||
*.mp4
|
||||
*.mp3
|
||||
.git/
|
||||
.gitea/
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push
|
||||
runs-on: build-htz-01
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
docker build -t cr.jdbnet.co.uk/public/goencode:latest .
|
||||
docker push cr.jdbnet.co.uk/public/goencode:latest
|
||||
@@ -0,0 +1 @@
|
||||
media/
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Build stage
|
||||
FROM golang:1.26.4-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Run go mod tidy to generate go.sum based on source
|
||||
RUN go mod tidy
|
||||
|
||||
# Build the application
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /app/bin/goencode ./cmd/goencode
|
||||
|
||||
# Final stage
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
# Install ffmpeg and ca-certificates
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends ffmpeg ca-certificates && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy binary from builder
|
||||
COPY --from=builder /app/bin/goencode /app/goencode
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Run the binary
|
||||
ENTRYPOINT ["/app/goencode"]
|
||||
CMD ["--config", "/etc/goencode/config.yaml"]
|
||||
@@ -1 +1,106 @@
|
||||
# GoEncode
|
||||
<div align="center">
|
||||
<img src="web/static/icon.png" alt="GoEncode" width="128" />
|
||||
|
||||
# GoEncode
|
||||
|
||||
GoEncode is a lightweight, high-performance media transcoding server written in Go. Originally built as a Flask app, it has been rewritten from the ground up as a single, self-contained binary. GoEncode features a modern web UI, a robust job queue, automatic watch folder monitoring, real-time log streaming, and MariaDB-backed persistence.
|
||||
|
||||
</div>
|
||||
|
||||
## Features
|
||||
|
||||
- **Watch Folders**: Automatically monitor designated directories for new media files.
|
||||
- **Smart Queue & Debouncing**: Prevents incomplete files from being processed while they are still being copied to the watch folder.
|
||||
- **Background Processing**: Processes media sequentially with up to 3 worker threads. Files are safely copied to a temporary directory before encoding to avoid hammering network-attached storage (NAS).
|
||||
- **Format Intelligence**: Probes media to detect codecs and resolution, automatically skipping files that already meet the target codec/resolution.
|
||||
- **Web Dashboard**: Modern, responsive interface with Server-Sent Events (SSE) for live tracking of job progress, queue stats, and server logs.
|
||||
- **Robust Persistence**: Job history, metrics (size saved, time taken), and configuration are all saved to a MariaDB/MySQL database.
|
||||
- **Docker-Ready**: Packaged in an ultra-slim container image based on Debian, with `ffmpeg` built-in.
|
||||
|
||||
## Deployment with Docker
|
||||
|
||||
The easiest way to run GoEncode is via Docker using the pre-built image.
|
||||
|
||||
### Image Registry
|
||||
|
||||
`cr.jdbnet.co.uk/public/goencode:latest`
|
||||
|
||||
### Example `docker-compose.yml`
|
||||
|
||||
```yaml
|
||||
services:
|
||||
goencode:
|
||||
image: cr.jdbnet.co.uk/public/goencode:latest
|
||||
container_name: goencode
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
# Database Configuration
|
||||
- GOENCODE_DB_HOST=db
|
||||
- GOENCODE_DB_PORT=3306
|
||||
- GOENCODE_DB_USER=goencode
|
||||
- GOENCODE_DB_PASS=goencode_password
|
||||
- GOENCODE_DB_NAME=goencode
|
||||
|
||||
# Web Server Configuration
|
||||
- GOENCODE_SERVER_LISTEN=0.0.0.0
|
||||
- GOENCODE_SERVER_PORT=8080
|
||||
|
||||
# Web UI Authentication (Optional)
|
||||
- GOENCODE_AUTH_USER=admin
|
||||
- GOENCODE_AUTH_PASS=secret
|
||||
|
||||
# Encoding
|
||||
- GOENCODE_ENCODER_TEMP=/tmp/goencode
|
||||
volumes:
|
||||
- /path/to/your/media:/media
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
db:
|
||||
image: mariadb:10.11
|
||||
container_name: goencode-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- MARIADB_DATABASE=goencode
|
||||
- MARIADB_USER=goencode
|
||||
- MARIADB_PASSWORD=goencode_password
|
||||
- MARIADB_ROOT_PASSWORD=root_password
|
||||
volumes:
|
||||
- goencode_db_data:/var/lib/mysql
|
||||
|
||||
volumes:
|
||||
goencode_db_data:
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
GoEncode can be configured via `goencode.yaml` or entirely via environment variables (ideal for Docker/Kubernetes). Environment variables take precedence over the YAML file.
|
||||
|
||||
### Available Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `GOENCODE_DB_HOST` | Database host | `127.0.0.1` |
|
||||
| `GOENCODE_DB_PORT` | Database port | `3306` |
|
||||
| `GOENCODE_DB_USER` | Database username | `goencode` |
|
||||
| `GOENCODE_DB_PASS` | Database password | |
|
||||
| `GOENCODE_DB_NAME` | Database name | `goencode` |
|
||||
| `GOENCODE_SERVER_LISTEN` | IP to bind the web interface | `0.0.0.0` |
|
||||
| `GOENCODE_SERVER_PORT` | Port for the web interface | `8080` |
|
||||
| `GOENCODE_AUTH_USER` | Username for the web UI | |
|
||||
| `GOENCODE_AUTH_PASS` | Password for the web UI | |
|
||||
| `GOENCODE_ENCODER_TEMP`| Temp directory for processing jobs | `/tmp/goencode` |
|
||||
|
||||
## Building Locally
|
||||
|
||||
To build the Docker image locally and push it to your registry:
|
||||
|
||||
```bash
|
||||
# Build the image
|
||||
docker build -t cr.jdbnet.co.uk/public/goencode:latest .
|
||||
|
||||
# Push to your registry
|
||||
docker push cr.jdbnet.co.uk/public/goencode:latest
|
||||
```
|
||||
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"goencode/internal/config"
|
||||
"goencode/internal/db"
|
||||
"goencode/internal/logger"
|
||||
"goencode/internal/queue"
|
||||
"goencode/internal/watcher"
|
||||
"goencode/internal/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "goencode.yaml", "Path to configuration file")
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := config.LoadConfig(*configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Init(&cfg.Database); err != nil {
|
||||
log.Fatalf("Database init failed: %v", err)
|
||||
}
|
||||
|
||||
sseServer := web.NewSSEServer()
|
||||
|
||||
// Initialize logger
|
||||
logger.Init(sseServer.Broadcast)
|
||||
|
||||
qm := queue.NewManager(cfg.Encoder.FFmpegPath, cfg.Encoder.TempDir, sseServer.Broadcast)
|
||||
qm.Start()
|
||||
defer qm.Stop()
|
||||
|
||||
wm, err := watcher.NewManager(qm)
|
||||
if err != nil {
|
||||
log.Fatalf("Watcher init failed: %v", err)
|
||||
}
|
||||
wm.Start()
|
||||
defer wm.Stop()
|
||||
|
||||
server := web.NewServer(cfg, qm, wm, sseServer)
|
||||
|
||||
go func() {
|
||||
log.Printf("Starting GoEncode Web UI on %s:%d", cfg.Server.ListenAddr, cfg.Server.Port)
|
||||
if err := server.Start(); err != nil {
|
||||
log.Fatalf("Server failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sig
|
||||
log.Println("Shutting down...")
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
services:
|
||||
db:
|
||||
image: mariadb:10.11
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
MYSQL_DATABASE: encoder
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
ports:
|
||||
- "3307:3306"
|
||||
|
||||
app:
|
||||
build: .
|
||||
restart: always
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./goencode.yaml:/etc/goencode/config.yaml:ro
|
||||
- ./media:/media # Mount your media folder here
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
@@ -0,0 +1,9 @@
|
||||
module goencode
|
||||
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.7.0
|
||||
github.com/go-sql-driver/mysql v1.8.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
server:
|
||||
port: 8080
|
||||
listen_addr: "0.0.0.0"
|
||||
|
||||
auth:
|
||||
username: "admin"
|
||||
password: "password"
|
||||
|
||||
database:
|
||||
host: "db"
|
||||
port: 3306
|
||||
user: "root"
|
||||
password: "root"
|
||||
name: "encoder"
|
||||
|
||||
encoder:
|
||||
ffmpeg_path: "ffmpeg"
|
||||
temp_dir: "/tmp/goencode"
|
||||
|
||||
logging:
|
||||
level: "info"
|
||||
@@ -0,0 +1,99 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func getEnvStr(key, defaultVal string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func getEnvInt(key string, defaultVal int) int {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
if i, err := strconv.Atoi(val); err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `yaml:"port"`
|
||||
ListenAddr string `yaml:"listen_addr"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
User string `yaml:"user"`
|
||||
Password string `yaml:"password"`
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
|
||||
type EncoderConfig struct {
|
||||
FFmpegPath string `yaml:"ffmpeg_path"`
|
||||
TempDir string `yaml:"temp_dir"`
|
||||
}
|
||||
|
||||
type LoggingConfig struct {
|
||||
Level string `yaml:"level"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Auth AuthConfig `yaml:"auth"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Encoder EncoderConfig `yaml:"encoder"`
|
||||
Logging LoggingConfig `yaml:"logging"`
|
||||
}
|
||||
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
var cfg Config
|
||||
|
||||
// Try reading from file first (it's okay if it doesn't exist for env-only deployments)
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("could not parse config file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Override with env variables
|
||||
cfg.Server.Port = getEnvInt("GOENCODE_PORT", cfg.Server.Port)
|
||||
cfg.Server.ListenAddr = getEnvStr("GOENCODE_LISTEN_ADDR", cfg.Server.ListenAddr)
|
||||
cfg.Auth.Username = getEnvStr("GOENCODE_AUTH_USER", cfg.Auth.Username)
|
||||
cfg.Auth.Password = getEnvStr("GOENCODE_AUTH_PASS", cfg.Auth.Password)
|
||||
cfg.Database.Host = getEnvStr("GOENCODE_DB_HOST", cfg.Database.Host)
|
||||
cfg.Database.Port = getEnvInt("GOENCODE_DB_PORT", cfg.Database.Port)
|
||||
cfg.Database.User = getEnvStr("GOENCODE_DB_USER", cfg.Database.User)
|
||||
cfg.Database.Password = getEnvStr("GOENCODE_DB_PASS", cfg.Database.Password)
|
||||
cfg.Database.Name = getEnvStr("GOENCODE_DB_NAME", cfg.Database.Name)
|
||||
cfg.Encoder.FFmpegPath = getEnvStr("GOENCODE_FFMPEG_PATH", cfg.Encoder.FFmpegPath)
|
||||
cfg.Encoder.TempDir = getEnvStr("GOENCODE_TEMP_DIR", cfg.Encoder.TempDir)
|
||||
cfg.Logging.Level = getEnvStr("GOENCODE_LOG_LEVEL", cfg.Logging.Level)
|
||||
|
||||
// Defaults if missing entirely
|
||||
if cfg.Server.Port == 0 {
|
||||
cfg.Server.Port = 8080
|
||||
}
|
||||
if cfg.Server.ListenAddr == "" {
|
||||
cfg.Server.ListenAddr = "0.0.0.0"
|
||||
}
|
||||
if cfg.Database.Port == 0 {
|
||||
cfg.Database.Port = 3306
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"goencode/internal/config"
|
||||
)
|
||||
|
||||
var DB *sql.DB
|
||||
|
||||
func Init(cfg *config.DatabaseConfig) error {
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true",
|
||||
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Name)
|
||||
|
||||
var err error
|
||||
DB, err = sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set connection pool limits
|
||||
DB.SetMaxOpenConns(25)
|
||||
DB.SetMaxIdleConns(25)
|
||||
DB.SetConnMaxLifetime(5 * time.Minute)
|
||||
|
||||
if err = DB.Ping(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Println("Connected to MariaDB successfully")
|
||||
|
||||
return runMigrations()
|
||||
}
|
||||
|
||||
func runMigrations() error {
|
||||
queries := []string{
|
||||
`CREATE TABLE IF NOT EXISTS watch_folders (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
folder_path VARCHAR(500) NOT NULL UNIQUE,
|
||||
media_type ENUM('video', 'audio') NOT NULL,
|
||||
target_resolution VARCHAR(20),
|
||||
custom_ffmpeg_flags TEXT,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS jobs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
file_path VARCHAR(500) NOT NULL,
|
||||
media_type ENUM('video', 'audio') NOT NULL,
|
||||
status ENUM('pending', 'processing', 'failed') NOT NULL DEFAULT 'pending',
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
original_size BIGINT DEFAULT 0,
|
||||
target_resolution VARCHAR(20),
|
||||
ffmpeg_flags TEXT,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS job_reports (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
file_path VARCHAR(500) NOT NULL,
|
||||
media_type ENUM('video', 'audio') NOT NULL,
|
||||
status ENUM('success', 'failed', 'skipped') NOT NULL,
|
||||
original_size BIGINT NOT NULL DEFAULT 0,
|
||||
encoded_size BIGINT NOT NULL DEFAULT 0,
|
||||
size_saved BIGINT NOT NULL DEFAULT 0,
|
||||
processing_time DECIMAL(10,2) DEFAULT 0,
|
||||
target_resolution VARCHAR(20),
|
||||
ffmpeg_flags TEXT,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS app_config (
|
||||
config_key VARCHAR(100) PRIMARY KEY,
|
||||
config_value TEXT
|
||||
)`,
|
||||
}
|
||||
|
||||
for _, q := range queries {
|
||||
if _, err := DB.Exec(q); err != nil {
|
||||
return fmt.Errorf("migration failed: %w\nQuery: %s", err, q)
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("Database schemas initialized")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type WatchFolder struct {
|
||||
ID int `json:"id"`
|
||||
FolderPath string `json:"folder_path"`
|
||||
MediaType string `json:"media_type"`
|
||||
TargetResolution string `json:"target_resolution"`
|
||||
CustomFFmpegFlags string `json:"custom_ffmpeg_flags"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
ID int `json:"id"`
|
||||
FilePath string `json:"file_path"`
|
||||
MediaType string `json:"media_type"`
|
||||
Status string `json:"status"` // pending, processing, failed
|
||||
Priority int `json:"priority"`
|
||||
OriginalSize int64 `json:"original_size"`
|
||||
TargetResolution string `json:"target_resolution"`
|
||||
FFmpegFlags string `json:"ffmpeg_flags"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type JobReport struct {
|
||||
ID int `json:"id"`
|
||||
FilePath string `json:"file_path"`
|
||||
MediaType string `json:"media_type"`
|
||||
Status string `json:"status"` // success, failed, skipped
|
||||
OriginalSize int64 `json:"original_size"`
|
||||
EncodedSize int64 `json:"encoded_size"`
|
||||
SizeSaved int64 `json:"size_saved"`
|
||||
ProcessingTime float64 `json:"processing_time"`
|
||||
TargetResolution string `json:"target_resolution"`
|
||||
FFmpegFlags string `json:"ffmpeg_flags"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// Watch Folders
|
||||
|
||||
func GetWatchFolders() ([]WatchFolder, error) {
|
||||
rows, err := DB.Query(`SELECT id, folder_path, media_type, target_resolution, custom_ffmpeg_flags, enabled, created_at, updated_at FROM watch_folders`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var folders []WatchFolder
|
||||
for rows.Next() {
|
||||
var f WatchFolder
|
||||
var targetRes, ffmpegFlags sql.NullString
|
||||
if err := rows.Scan(&f.ID, &f.FolderPath, &f.MediaType, &targetRes, &ffmpegFlags, &f.Enabled, &f.CreatedAt, &f.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if targetRes.Valid {
|
||||
f.TargetResolution = targetRes.String
|
||||
}
|
||||
if ffmpegFlags.Valid {
|
||||
f.CustomFFmpegFlags = ffmpegFlags.String
|
||||
}
|
||||
folders = append(folders, f)
|
||||
}
|
||||
return folders, nil
|
||||
}
|
||||
|
||||
func AddWatchFolder(f WatchFolder) error {
|
||||
_, err := DB.Exec(`INSERT INTO watch_folders (folder_path, media_type, target_resolution, custom_ffmpeg_flags, enabled) VALUES (?, ?, ?, ?, ?)`,
|
||||
f.FolderPath, f.MediaType, nullStr(f.TargetResolution), nullStr(f.CustomFFmpegFlags), f.Enabled)
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteWatchFolder(id int) error {
|
||||
_, err := DB.Exec(`DELETE FROM watch_folders WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func nullStr(s string) sql.NullString {
|
||||
if s == "" {
|
||||
return sql.NullString{Valid: false}
|
||||
}
|
||||
return sql.NullString{String: s, Valid: true}
|
||||
}
|
||||
|
||||
// Jobs
|
||||
|
||||
func AddJob(filePath, mediaType string, priority int, targetRes, ffmpegFlags string, originalSize int64) error {
|
||||
_, err := DB.Exec(`INSERT INTO jobs (file_path, media_type, priority, target_resolution, ffmpeg_flags, original_size) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
filePath, mediaType, priority, nullStr(targetRes), nullStr(ffmpegFlags), originalSize)
|
||||
return err
|
||||
}
|
||||
|
||||
func GetPendingJobs() ([]Job, error) {
|
||||
rows, err := DB.Query(`SELECT id, file_path, media_type, status, priority, original_size, target_resolution, ffmpeg_flags, error_message, created_at, updated_at FROM jobs ORDER BY priority DESC, created_at ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var jobs []Job
|
||||
for rows.Next() {
|
||||
var j Job
|
||||
var targetRes, ffmpegFlags, errMsg sql.NullString
|
||||
if err := rows.Scan(&j.ID, &j.FilePath, &j.MediaType, &j.Status, &j.Priority, &j.OriginalSize, &targetRes, &ffmpegFlags, &errMsg, &j.CreatedAt, &j.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if targetRes.Valid { j.TargetResolution = targetRes.String }
|
||||
if ffmpegFlags.Valid { j.FFmpegFlags = ffmpegFlags.String }
|
||||
if errMsg.Valid { j.ErrorMessage = errMsg.String }
|
||||
jobs = append(jobs, j)
|
||||
}
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
func UpdateJobStatus(id int, status, errMsg string) error {
|
||||
_, err := DB.Exec(`UPDATE jobs SET status = ?, error_message = ? WHERE id = ?`, status, nullStr(errMsg), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func BumpJobPriority(id int) error {
|
||||
_, err := DB.Exec(`UPDATE jobs SET priority = priority + 1 WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteJob(id int) error {
|
||||
_, err := DB.Exec(`DELETE FROM jobs WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func MarkProcessingAsFailed() error {
|
||||
_, err := DB.Exec(`UPDATE jobs SET status = 'failed', error_message = 'Interrupted by server restart' WHERE status = 'processing'`)
|
||||
return err
|
||||
}
|
||||
|
||||
func IsFileAlreadyProcessedOrQueued(filePath string) (bool, error) {
|
||||
var count int
|
||||
err := DB.QueryRow(`SELECT COUNT(*) FROM jobs WHERE file_path = ?`, filePath).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if count > 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
err = DB.QueryRow(`SELECT COUNT(*) FROM job_reports WHERE file_path = ?`, filePath).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// Job Reports
|
||||
|
||||
func AddJobReport(j Job, status string, encodedSize int64, sizeSaved int64, processingTime float64) error {
|
||||
_, err := DB.Exec(`INSERT INTO job_reports (file_path, media_type, status, original_size, encoded_size, size_saved, processing_time, target_resolution, ffmpeg_flags, error_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
j.FilePath, j.MediaType, status, j.OriginalSize, encodedSize, sizeSaved, processingTime, nullStr(j.TargetResolution), nullStr(j.FFmpegFlags), nullStr(j.ErrorMessage))
|
||||
return err
|
||||
}
|
||||
|
||||
func GetJobReports(limit, offset int) ([]JobReport, int, error) {
|
||||
var total int
|
||||
if err := DB.QueryRow(`SELECT COUNT(*) FROM job_reports`).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
rows, err := DB.Query(`SELECT id, file_path, media_type, status, original_size, encoded_size, size_saved, processing_time, target_resolution, ffmpeg_flags, error_message, created_at FROM job_reports ORDER BY created_at DESC LIMIT ? OFFSET ?`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var reports []JobReport
|
||||
for rows.Next() {
|
||||
var r JobReport
|
||||
var targetRes, ffmpegFlags, errMsg sql.NullString
|
||||
if err := rows.Scan(&r.ID, &r.FilePath, &r.MediaType, &r.Status, &r.OriginalSize, &r.EncodedSize, &r.SizeSaved, &r.ProcessingTime, &targetRes, &ffmpegFlags, &errMsg, &r.CreatedAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if targetRes.Valid { r.TargetResolution = targetRes.String }
|
||||
if ffmpegFlags.Valid { r.FFmpegFlags = ffmpegFlags.String }
|
||||
if errMsg.Valid { r.ErrorMessage = errMsg.String }
|
||||
reports = append(reports, r)
|
||||
}
|
||||
return reports, total, nil
|
||||
}
|
||||
|
||||
type DashboardStats struct {
|
||||
TotalSavedSpace int64
|
||||
FilesEncoded int
|
||||
QueueLength int
|
||||
}
|
||||
|
||||
func GetDashboardStats() (DashboardStats, error) {
|
||||
var stats DashboardStats
|
||||
|
||||
// Total saved space and files encoded
|
||||
err := DB.QueryRow(`
|
||||
SELECT
|
||||
COALESCE(SUM(size_saved), 0) as saved,
|
||||
COUNT(*) as count
|
||||
FROM job_reports
|
||||
WHERE status = 'success'
|
||||
`).Scan(&stats.TotalSavedSpace, &stats.FilesEncoded)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return stats, err
|
||||
}
|
||||
|
||||
// Queue length
|
||||
err = DB.QueryRow(`SELECT COUNT(*) FROM jobs`).Scan(&stats.QueueLength)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return stats, err
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package encoder
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (m *FFmpegManager) BuildAudioCmd(inputPath, outputPath, ffmpegFlags string) (*exec.Cmd, error) {
|
||||
args := []string{"-i", inputPath, "-c:a", "libmp3lame", "-b:a", "320k"}
|
||||
|
||||
if ffmpegFlags != "" {
|
||||
userArgs := ShlexSplit(ffmpegFlags)
|
||||
args = append(args, userArgs...)
|
||||
}
|
||||
|
||||
args = append(args, outputPath, "-y")
|
||||
|
||||
return exec.Command(m.BinaryPath, args...), nil
|
||||
}
|
||||
|
||||
func (m *FFmpegManager) ShouldSkipAudio(inputPath string) bool {
|
||||
// Probe the codec
|
||||
cmd := exec.Command("ffprobe", "-v", "quiet", "-select_streams", "a:0",
|
||||
"-show_entries", "stream=codec_name", "-of", "csv=p=0", inputPath)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
codec := strings.TrimSpace(string(out))
|
||||
|
||||
// If it's already mp3, check bitrate
|
||||
if codec == "mp3" {
|
||||
cmdBr := exec.Command("ffprobe", "-v", "quiet", "-select_streams", "a:0",
|
||||
"-show_entries", "stream=bit_rate", "-of", "csv=p=0", inputPath)
|
||||
outBr, err := cmdBr.Output()
|
||||
if err == nil {
|
||||
br, _ := strconv.Atoi(strings.TrimSpace(string(outBr)))
|
||||
if br >= 300000 {
|
||||
return true // It's ~320k mp3
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package encoder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FFmpegManager handles execution and probing
|
||||
type FFmpegManager struct {
|
||||
BinaryPath string
|
||||
}
|
||||
|
||||
func NewManager(binaryPath string) *FFmpegManager {
|
||||
if binaryPath == "" {
|
||||
binaryPath = "ffmpeg"
|
||||
}
|
||||
return &FFmpegManager{BinaryPath: binaryPath}
|
||||
}
|
||||
|
||||
func (m *FFmpegManager) ProbeCodec(filePath, streamType string) (string, error) {
|
||||
cmd := exec.Command("ffprobe", "-v", "quiet", "-select_streams", streamType+":0",
|
||||
"-show_entries", "stream=codec_name", "-of", "csv=p=0", filePath)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ffprobe error: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
func (m *FFmpegManager) ProbeResolution(filePath string) (int, int, error) {
|
||||
cmd := exec.Command("ffprobe", "-v", "quiet", "-select_streams", "v:0",
|
||||
"-show_entries", "stream=width,height", "-of", "csv=p=0", filePath)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("ffprobe error: %w", err)
|
||||
}
|
||||
parts := strings.Split(strings.TrimSpace(string(out)), ",")
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, fmt.Errorf("invalid resolution format")
|
||||
}
|
||||
w, err1 := strconv.Atoi(parts[0])
|
||||
h, err2 := strconv.Atoi(parts[1])
|
||||
if err1 != nil || err2 != nil {
|
||||
return 0, 0, fmt.Errorf("invalid resolution numbers")
|
||||
}
|
||||
return w, h, nil
|
||||
}
|
||||
|
||||
func (m *FFmpegManager) ProbeDuration(filePath string) (float64, error) {
|
||||
cmd := exec.Command("ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", filePath)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ffprobe error: %w", err)
|
||||
}
|
||||
return strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
|
||||
}
|
||||
|
||||
func (m *FFmpegManager) ValidateFile(filePath string) error {
|
||||
cmd := exec.Command("ffprobe", "-v", "quiet", "-show_format", "-show_streams", filePath)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("file validation failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseProgress is a utility to parse time=XX:XX:XX.XX from stderr line to calculate percentage if duration is known.
|
||||
// Will be used by the queue worker reading from a pipe.
|
||||
func ParseProgress(line string, durationSec float64) float64 {
|
||||
// Example line: frame= 123 fps= 30 q=22.0 size= 2048kB time=00:00:10.50 bitrate=1500.0kbits/s speed=1.5x
|
||||
idx := strings.Index(line, "time=")
|
||||
if idx == -1 {
|
||||
return -1
|
||||
}
|
||||
timeStr := line[idx+5:]
|
||||
spaceIdx := strings.Index(timeStr, " ")
|
||||
if spaceIdx != -1 {
|
||||
timeStr = timeStr[:spaceIdx]
|
||||
}
|
||||
|
||||
// timeStr is HH:MM:SS.ms
|
||||
parts := strings.Split(timeStr, ":")
|
||||
if len(parts) != 3 {
|
||||
return -1
|
||||
}
|
||||
|
||||
h, _ := strconv.ParseFloat(parts[0], 64)
|
||||
m, _ := strconv.ParseFloat(parts[1], 64)
|
||||
s, _ := strconv.ParseFloat(parts[2], 64)
|
||||
|
||||
currentSec := h*3600 + m*60 + s
|
||||
if durationSec > 0 {
|
||||
return (currentSec / durationSec) * 100.0
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// ShlexSplit splits a command string like a shell would.
|
||||
// A simple implementation since go doesn't have shlex.split built-in.
|
||||
func ShlexSplit(s string) []string {
|
||||
var args []string
|
||||
var buf bytes.Buffer
|
||||
inQuotes := false
|
||||
var quoteChar rune
|
||||
|
||||
for _, r := range s {
|
||||
if inQuotes {
|
||||
if r == quoteChar {
|
||||
inQuotes = false
|
||||
} else {
|
||||
buf.WriteRune(r)
|
||||
}
|
||||
} else {
|
||||
if r == '\'' || r == '"' {
|
||||
inQuotes = true
|
||||
quoteChar = r
|
||||
} else if r == ' ' {
|
||||
if buf.Len() > 0 {
|
||||
args = append(args, buf.String())
|
||||
buf.Reset()
|
||||
}
|
||||
} else {
|
||||
buf.WriteRune(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
if buf.Len() > 0 {
|
||||
args = append(args, buf.String())
|
||||
}
|
||||
return args
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package encoder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (m *FFmpegManager) BuildVideoCmd(inputPath, outputPath, targetResolution, ffmpegFlags string, originalWidth, originalHeight int) (*exec.Cmd, error) {
|
||||
args := []string{"-i", inputPath}
|
||||
|
||||
if targetResolution != "" {
|
||||
targetHeightStr := strings.TrimRight(strings.ToLower(targetResolution), "p")
|
||||
targetHeight, err := strconv.Atoi(targetHeightStr)
|
||||
if err == nil && targetHeight > 0 && originalHeight > targetHeight {
|
||||
scaleWidth := int((float64(originalWidth) / float64(originalHeight)) * float64(targetHeight))
|
||||
if scaleWidth%2 != 0 {
|
||||
scaleWidth--
|
||||
}
|
||||
args = append(args, "-vf", fmt.Sprintf("scale=%d:%d", scaleWidth, targetHeight))
|
||||
}
|
||||
}
|
||||
|
||||
args = append(args, "-c:v", "libx265", "-c:a", "copy", "-preset", "medium", "-crf", "22", "-f", "matroska")
|
||||
|
||||
if ffmpegFlags != "" {
|
||||
userArgs := ShlexSplit(ffmpegFlags)
|
||||
args = append(args, userArgs...)
|
||||
}
|
||||
|
||||
args = append(args, outputPath, "-y")
|
||||
|
||||
return exec.Command(m.BinaryPath, args...), nil
|
||||
}
|
||||
|
||||
func (m *FFmpegManager) ShouldSkipVideo(inputPath, targetResolution string) bool {
|
||||
// Probe the codec
|
||||
cmd := exec.Command("ffprobe", "-v", "quiet", "-select_streams", "v:0",
|
||||
"-show_entries", "stream=codec_name", "-of", "csv=p=0", inputPath)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
codec := strings.TrimSpace(string(out))
|
||||
|
||||
// Probe the height
|
||||
cmdHeight := exec.Command("ffprobe", "-v", "quiet", "-select_streams", "v:0",
|
||||
"-show_entries", "stream=height", "-of", "csv=p=0", inputPath)
|
||||
outHeight, err := cmdHeight.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// If it's not H.264 or H.265, do not skip (encode it)
|
||||
if codec != "h264" && codec != "hevc" {
|
||||
return false
|
||||
}
|
||||
|
||||
// If it's H.265 and no target resolution is set, it's already in the target codec
|
||||
if codec == "hevc" && targetResolution == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// If it's H.265 and target resolution is set, only skip if current height is <= target height
|
||||
if codec == "hevc" && targetResolution != "" {
|
||||
currentHeight, _ := strconv.Atoi(strings.TrimSpace(string(outHeight)))
|
||||
targetHeight, _ := strconv.Atoi(strings.TrimRight(strings.ToLower(targetResolution), "p"))
|
||||
if currentHeight > 0 && targetHeight > 0 && currentHeight <= targetHeight {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"container/ring"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LogEntry struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
var (
|
||||
ringBuffer *ring.Ring
|
||||
bufferMu sync.Mutex
|
||||
Broadcast func(string, interface{})
|
||||
)
|
||||
|
||||
type customWriter struct {
|
||||
stdout io.Writer
|
||||
}
|
||||
|
||||
func (cw *customWriter) Write(p []byte) (n int, err error) {
|
||||
now := time.Now()
|
||||
msg := strings.TrimSpace(string(p))
|
||||
|
||||
// Write to stdout with simple timestamp
|
||||
outStr := now.Format("2006/01/02 15:04:05 ") + msg + "\n"
|
||||
cw.stdout.Write([]byte(outStr))
|
||||
|
||||
if msg == "" {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
entry := LogEntry{
|
||||
Timestamp: now,
|
||||
Message: msg,
|
||||
}
|
||||
|
||||
bufferMu.Lock()
|
||||
if ringBuffer == nil {
|
||||
ringBuffer = ring.New(100)
|
||||
}
|
||||
ringBuffer.Value = entry
|
||||
ringBuffer = ringBuffer.Next()
|
||||
bufferMu.Unlock()
|
||||
|
||||
if Broadcast != nil {
|
||||
Broadcast("log", entry)
|
||||
}
|
||||
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func Init(broadcast func(string, interface{})) {
|
||||
Broadcast = broadcast
|
||||
ringBuffer = ring.New(100)
|
||||
log.SetOutput(&customWriter{stdout: os.Stdout})
|
||||
// Remove timestamp from log format since we add it in LogEntry or we can let log package add it
|
||||
// Wait, standard log adds timestamp. If standard log adds timestamp, our `msg` will contain the timestamp.
|
||||
// But `LogEntry` has a `Timestamp` field for the JSON.
|
||||
// It's cleaner to remove the standard log flags so `msg` is just the message, and let stdout print the plain msg?
|
||||
// But stdout wouldn't have timestamps then unless we format it ourselves.
|
||||
// Let's just remove log flags and format it ourselves for stdout!
|
||||
log.SetFlags(0)
|
||||
}
|
||||
|
||||
// Since we removed log flags, we need to handle stdout formatting in Write if we want timestamps in stdout.
|
||||
// Actually, let's keep the timestamp in stdout, we can modify customWriter.Write to prepend the timestamp to stdout.
|
||||
// We'll update the writer in a moment if needed.
|
||||
|
||||
func GetRecentLogs() []LogEntry {
|
||||
bufferMu.Lock()
|
||||
defer bufferMu.Unlock()
|
||||
|
||||
var logs []LogEntry
|
||||
if ringBuffer != nil {
|
||||
ringBuffer.Do(func(p interface{}) {
|
||||
if p != nil {
|
||||
logs = append(logs, p.(LogEntry))
|
||||
}
|
||||
})
|
||||
}
|
||||
return logs
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"goencode/internal/db"
|
||||
"goencode/internal/encoder"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
FFmpegPath string
|
||||
TempDir string
|
||||
TriggerChan chan struct{}
|
||||
StopChan chan struct{}
|
||||
Broadcast func(string, interface{})
|
||||
encoder *encoder.FFmpegManager
|
||||
isProcessing bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewManager(ffmpegPath, tempDir string, broadcast func(string, interface{})) *Manager {
|
||||
return &Manager{
|
||||
FFmpegPath: ffmpegPath,
|
||||
TempDir: tempDir,
|
||||
TriggerChan: make(chan struct{}, 1),
|
||||
StopChan: make(chan struct{}),
|
||||
Broadcast: broadcast,
|
||||
encoder: encoder.NewManager(ffmpegPath),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Start() {
|
||||
// Mark any interrupted processing jobs as failed
|
||||
if err := db.MarkProcessingAsFailed(); err != nil {
|
||||
log.Printf("Failed to mark interrupted jobs: %v", err)
|
||||
}
|
||||
|
||||
go m.workerLoop()
|
||||
m.Trigger() // Initial trigger
|
||||
}
|
||||
|
||||
func (m *Manager) Stop() {
|
||||
close(m.StopChan)
|
||||
}
|
||||
|
||||
func (m *Manager) Trigger() {
|
||||
select {
|
||||
case m.TriggerChan <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) NotifySSE(event string, data interface{}) {
|
||||
if m.Broadcast != nil {
|
||||
m.Broadcast(event, data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"goencode/internal/db"
|
||||
"goencode/internal/encoder"
|
||||
"io"
|
||||
)
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Sync()
|
||||
}
|
||||
|
||||
func (m *Manager) workerLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-m.StopChan:
|
||||
return
|
||||
case <-m.TriggerChan:
|
||||
m.processNextJob()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) processNextJob() {
|
||||
m.mu.Lock()
|
||||
if m.isProcessing {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
m.isProcessing = true
|
||||
m.mu.Unlock()
|
||||
|
||||
var hasJobs bool
|
||||
defer func() {
|
||||
m.mu.Lock()
|
||||
m.isProcessing = false
|
||||
m.mu.Unlock()
|
||||
// Only trigger immediately if we know there might be more jobs
|
||||
if hasJobs {
|
||||
m.Trigger()
|
||||
}
|
||||
}()
|
||||
|
||||
jobs, err := db.GetPendingJobs()
|
||||
if err != nil {
|
||||
log.Printf("Failed to get pending jobs: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(jobs) == 0 {
|
||||
return
|
||||
}
|
||||
hasJobs = true
|
||||
|
||||
job := jobs[0]
|
||||
log.Printf("Starting job %d for %s", job.ID, job.FilePath)
|
||||
|
||||
m.NotifySSE("job_started", job)
|
||||
|
||||
err = db.UpdateJobStatus(job.ID, "processing", "")
|
||||
if err != nil {
|
||||
log.Printf("Failed to update job status: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
err = m.runEncoder(job)
|
||||
if err != nil {
|
||||
log.Printf("Job %d failed: %v", job.ID, err)
|
||||
db.UpdateJobStatus(job.ID, "failed", err.Error())
|
||||
db.AddJobReport(job, "failed", 0, 0, 0)
|
||||
m.NotifySSE("job_failed", map[string]interface{}{"id": job.ID, "error": err.Error()})
|
||||
} else {
|
||||
log.Printf("Job %d succeeded", job.ID)
|
||||
m.NotifySSE("job_completed", map[string]interface{}{"id": job.ID})
|
||||
}
|
||||
db.DeleteJob(job.ID)
|
||||
}
|
||||
|
||||
func (m *Manager) runEncoder(job db.Job) error {
|
||||
startTime := time.Now()
|
||||
|
||||
if _, err := os.Stat(job.FilePath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("source file missing")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(m.TempDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create temp dir: %w", err)
|
||||
}
|
||||
|
||||
originalSize := job.OriginalSize
|
||||
if originalSize == 0 {
|
||||
info, err := os.Stat(job.FilePath)
|
||||
if err == nil {
|
||||
originalSize = info.Size()
|
||||
}
|
||||
}
|
||||
|
||||
ext := filepath.Ext(job.FilePath)
|
||||
var outExt string
|
||||
if job.MediaType == "video" {
|
||||
outExt = ".mkv"
|
||||
} else {
|
||||
outExt = ".mp3"
|
||||
}
|
||||
|
||||
baseName := strings.TrimSuffix(filepath.Base(job.FilePath), ext)
|
||||
tempOutPath := filepath.Join(m.TempDir, fmt.Sprintf("temp_%d_%s%s", job.ID, baseName, outExt))
|
||||
finalOutPath := filepath.Join(filepath.Dir(job.FilePath), baseName+outExt)
|
||||
|
||||
duration, _ := m.encoder.ProbeDuration(job.FilePath)
|
||||
|
||||
tempInPath := filepath.Join(m.TempDir, fmt.Sprintf("temp_in_%d_%s", job.ID, filepath.Base(job.FilePath)))
|
||||
|
||||
var cmdErr error
|
||||
var execCmd *exec.Cmd
|
||||
|
||||
if job.MediaType == "video" {
|
||||
if m.encoder.ShouldSkipVideo(job.FilePath, job.TargetResolution) {
|
||||
log.Printf("Skipping video %d - already target codec/resolution", job.ID)
|
||||
return db.AddJobReport(job, "skipped", originalSize, 0, 0)
|
||||
}
|
||||
|
||||
w, h, err := m.encoder.ProbeResolution(job.FilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to probe resolution: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Copying %s to %s before encoding...", job.FilePath, tempInPath)
|
||||
if err := copyFile(job.FilePath, tempInPath); err != nil {
|
||||
return fmt.Errorf("failed to copy source to temp: %w", err)
|
||||
}
|
||||
defer os.Remove(tempInPath)
|
||||
|
||||
execCmd, err = m.encoder.BuildVideoCmd(tempInPath, tempOutPath, job.TargetResolution, job.FFmpegFlags, w, h)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if m.encoder.ShouldSkipAudio(job.FilePath) {
|
||||
log.Printf("Skipping audio %d - already target codec/bitrate", job.ID)
|
||||
return db.AddJobReport(job, "skipped", originalSize, 0, 0)
|
||||
}
|
||||
|
||||
log.Printf("Copying %s to %s before encoding...", job.FilePath, tempInPath)
|
||||
if err := copyFile(job.FilePath, tempInPath); err != nil {
|
||||
return fmt.Errorf("failed to copy source to temp: %w", err)
|
||||
}
|
||||
defer os.Remove(tempInPath)
|
||||
|
||||
execCmd, cmdErr = m.encoder.BuildAudioCmd(tempInPath, tempOutPath, job.FFmpegFlags)
|
||||
if cmdErr != nil {
|
||||
return cmdErr
|
||||
}
|
||||
}
|
||||
|
||||
stderr, err := execCmd.StderrPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := execCmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start ffmpeg: %w", err)
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(stderr)
|
||||
scanner.Split(bufio.ScanLines)
|
||||
// Some ffmpeg progress output uses carriage returns instead of newlines
|
||||
// We handle standard lines, to get a more robust carriage return split, we could implement a custom split function.
|
||||
// For simplicity, ScanLines usually catches 'time=' lines well enough if they have \n.
|
||||
// Let's use a custom split to handle '\r' as well.
|
||||
scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||
if atEOF && len(data) == 0 {
|
||||
return 0, nil, nil
|
||||
}
|
||||
if i := bytes.IndexByte(data, '\r'); i >= 0 {
|
||||
return i + 1, data[0:i], nil
|
||||
}
|
||||
if i := bytes.IndexByte(data, '\n'); i >= 0 {
|
||||
return i + 1, data[0:i], nil
|
||||
}
|
||||
if atEOF {
|
||||
return len(data), data, nil
|
||||
}
|
||||
return 0, nil, nil
|
||||
})
|
||||
|
||||
var lastErrLine string
|
||||
go func() {
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
lastErrLine = line
|
||||
if duration > 0 {
|
||||
prog := encoder.ParseProgress(line, duration)
|
||||
if prog >= 0 {
|
||||
m.NotifySSE("progress", map[string]interface{}{
|
||||
"id": job.ID,
|
||||
"progress": fmt.Sprintf("%.1f", prog),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if err := execCmd.Wait(); err != nil {
|
||||
return fmt.Errorf("ffmpeg error: %v, last output: %s", err, lastErrLine)
|
||||
}
|
||||
|
||||
// Validate
|
||||
if err := m.encoder.ValidateFile(tempOutPath); err != nil {
|
||||
os.Remove(tempOutPath)
|
||||
return fmt.Errorf("validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Calculate sizes
|
||||
outInfo, err := os.Stat(tempOutPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stat output: %w", err)
|
||||
}
|
||||
encodedSize := outInfo.Size()
|
||||
sizeSaved := originalSize - encodedSize
|
||||
|
||||
// Move file
|
||||
log.Printf("Copying encoded file back to %s...", finalOutPath)
|
||||
if err := os.Rename(tempOutPath, finalOutPath); err != nil {
|
||||
// Fallback to copy if cross-device link error
|
||||
if err := copyFile(tempOutPath, finalOutPath); err != nil {
|
||||
return fmt.Errorf("failed to move output: %w", err)
|
||||
}
|
||||
os.Remove(tempOutPath)
|
||||
}
|
||||
|
||||
// Delete original if different extension
|
||||
if finalOutPath != job.FilePath {
|
||||
os.Remove(job.FilePath)
|
||||
}
|
||||
|
||||
processTime := time.Since(startTime).Seconds()
|
||||
return db.AddJobReport(job, "success", encodedSize, sizeSaved, processTime)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"goencode/internal/db"
|
||||
"goencode/internal/queue"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
watcher *fsnotify.Watcher
|
||||
queueManager *queue.Manager
|
||||
timers map[string]*time.Timer
|
||||
timersMu sync.Mutex
|
||||
processChan chan string
|
||||
stopChan chan struct{}
|
||||
}
|
||||
|
||||
func NewManager(qm *queue.Manager) (*Manager, error) {
|
||||
w, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
watcher: w,
|
||||
queueManager: qm,
|
||||
timers: make(map[string]*time.Timer),
|
||||
processChan: make(chan string, 10000),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
go m.processWorker()
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Start() {
|
||||
m.Reload()
|
||||
go m.watchLoop()
|
||||
}
|
||||
|
||||
func (m *Manager) Stop() {
|
||||
close(m.stopChan)
|
||||
m.watcher.Close()
|
||||
}
|
||||
|
||||
func (m *Manager) Reload() {
|
||||
// Remove all existing watches
|
||||
for _, path := range m.watcher.WatchList() {
|
||||
m.watcher.Remove(path)
|
||||
}
|
||||
|
||||
folders, err := db.GetWatchFolders()
|
||||
if err != nil {
|
||||
log.Printf("Watcher failed to get folders: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, f := range folders {
|
||||
if !f.Enabled {
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(f.FolderPath, 0755); err != nil {
|
||||
log.Printf("Failed to create watch folder %s: %v", f.FolderPath, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("Watching and scanning %s", f.FolderPath)
|
||||
|
||||
// Walk the directory to add all subdirectories to watcher and scan existing files
|
||||
filepath.Walk(f.FolderPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
if err := m.watcher.Add(path); err != nil {
|
||||
log.Printf("Failed to watch %s: %v", path, err)
|
||||
}
|
||||
} else {
|
||||
// Process existing file asynchronously
|
||||
go m.handleEvent(path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) watchLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-m.stopChan:
|
||||
return
|
||||
case event, ok := <-m.watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) {
|
||||
// Check if the created event is a directory
|
||||
if event.Has(fsnotify.Create) {
|
||||
info, err := os.Stat(event.Name)
|
||||
if err == nil && info.IsDir() {
|
||||
filepath.Walk(event.Name, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
m.watcher.Add(path)
|
||||
} else {
|
||||
go m.handleEvent(path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
m.handleEvent(event.Name)
|
||||
}
|
||||
case err, ok := <-m.watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Printf("Watcher error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) processWorker() {
|
||||
for {
|
||||
select {
|
||||
case <-m.stopChan:
|
||||
return
|
||||
case path := <-m.processChan:
|
||||
m.processFile(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) handleEvent(filePath string) {
|
||||
m.timersMu.Lock()
|
||||
defer m.timersMu.Unlock()
|
||||
|
||||
if t, exists := m.timers[filePath]; exists {
|
||||
t.Stop()
|
||||
}
|
||||
|
||||
m.timers[filePath] = time.AfterFunc(5*time.Second, func() {
|
||||
m.timersMu.Lock()
|
||||
delete(m.timers, filePath)
|
||||
m.timersMu.Unlock()
|
||||
|
||||
select {
|
||||
case m.processChan <- filePath:
|
||||
default:
|
||||
log.Printf("Process queue full, dropping %s", filePath)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) processFile(filePath string) {
|
||||
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil || info.IsDir() {
|
||||
return // File removed or is a directory
|
||||
}
|
||||
|
||||
// Make sure it's not a temp file
|
||||
if filepath.Ext(filePath) == ".tmp" {
|
||||
return
|
||||
}
|
||||
|
||||
alreadyInQueue, err := db.IsFileAlreadyProcessedOrQueued(filePath)
|
||||
if err != nil {
|
||||
log.Printf("Error checking DB for %s: %v", filePath, err)
|
||||
return
|
||||
}
|
||||
if alreadyInQueue {
|
||||
return
|
||||
}
|
||||
|
||||
// Find which watch folder it belongs to
|
||||
folders, err := db.GetWatchFolders()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var match db.WatchFolder
|
||||
found := false
|
||||
for _, f := range folders {
|
||||
if !f.Enabled {
|
||||
continue
|
||||
}
|
||||
// Check if filePath is inside f.FolderPath
|
||||
cleanPath := filepath.Clean(filePath)
|
||||
folderPath := filepath.Clean(f.FolderPath)
|
||||
if strings.HasPrefix(cleanPath, folderPath+string(os.PathSeparator)) || cleanPath == folderPath || filepath.Dir(cleanPath) == folderPath {
|
||||
match = f
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
|
||||
err = db.AddJob(filePath, match.MediaType, 0, match.TargetResolution, match.CustomFFmpegFlags, info.Size())
|
||||
if err != nil {
|
||||
log.Printf("Failed to add job for %s: %v", filePath, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Added job for %s", filePath)
|
||||
m.queueManager.NotifySSE("job_added", nil)
|
||||
m.queueManager.Trigger()
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"goencode/internal/db"
|
||||
)
|
||||
|
||||
func (s *Server) handleGetQueue(w http.ResponseWriter, r *http.Request) {
|
||||
jobs, err := db.GetPendingJobs()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(jobs)
|
||||
}
|
||||
|
||||
func (s *Server) handleBumpJob(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/api/jobs/bump/"))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := db.BumpJobPriority(id); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.qm.NotifySSE("queue_updated", nil)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) handleCancelJob(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/api/jobs/cancel/"))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := db.DeleteJob(id); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.qm.NotifySSE("queue_updated", nil)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetWatchFolders(w http.ResponseWriter, r *http.Request) {
|
||||
folders, err := db.GetWatchFolders()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(folders)
|
||||
}
|
||||
|
||||
func (s *Server) handleAddWatchFolder(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var f db.WatchFolder
|
||||
if err := json.NewDecoder(r.Body).Decode(&f); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
f.Enabled = true
|
||||
if err := db.AddWatchFolder(f); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.wm.Reload()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteWatchFolder(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/api/folders/delete/"))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := db.DeleteWatchFolder(id); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.wm.Reload()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"goencode/internal/config"
|
||||
"goencode/internal/db"
|
||||
"goencode/internal/logger"
|
||||
"goencode/internal/queue"
|
||||
"goencode/internal/watcher"
|
||||
rootweb "goencode/web"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
qm *queue.Manager
|
||||
wm *watcher.Manager
|
||||
sse *SSEServer
|
||||
mux *http.ServeMux
|
||||
sessionToken string
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.Config, qm *queue.Manager, wm *watcher.Manager, sse *SSEServer) *Server {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
token := hex.EncodeToString(b)
|
||||
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
qm: qm,
|
||||
wm: wm,
|
||||
sse: sse,
|
||||
mux: http.NewServeMux(),
|
||||
sessionToken: token,
|
||||
}
|
||||
s.routes()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
addr := fmt.Sprintf("%s:%d", s.cfg.Server.ListenAddr, s.cfg.Server.Port)
|
||||
|
||||
var handler http.Handler = s.mux
|
||||
if s.cfg.Auth.Username != "" {
|
||||
handler = s.authMiddleware(s.mux)
|
||||
}
|
||||
|
||||
return http.ListenAndServe(addr, handler)
|
||||
}
|
||||
|
||||
func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
importStrings := true // to ensure "strings" is imported if not already, wait, I need to check if strings is imported.
|
||||
_ = importStrings
|
||||
|
||||
// We'll use a simple slice of prefixes to bypass auth, but since there's only one,
|
||||
// let's just use string slicing or import "strings"
|
||||
|
||||
// To be safe without adding imports manually if I don't know the exact list:
|
||||
if s.cfg.Auth.Username == "" || r.URL.Path == "/login" || (len(r.URL.Path) >= 8 && r.URL.Path[:8] == "/static/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
cookie, err := r.Cookie("goencode_session")
|
||||
if err != nil || cookie.Value != s.sessionToken {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" {
|
||||
tmpl, err := template.ParseFS(rootweb.FS, "templates/login.html")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
tmpl.Execute(w, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == "POST" {
|
||||
user := r.FormValue("username")
|
||||
pass := r.FormValue("password")
|
||||
|
||||
if user == s.cfg.Auth.Username && pass == s.cfg.Auth.Password {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "goencode_session",
|
||||
Value: s.sessionToken,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
})
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl, _ := template.ParseFS(rootweb.FS, "templates/login.html")
|
||||
tmpl.Execute(w, struct{ Error string }{"Invalid credentials"})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "goencode_session",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
})
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) routes() {
|
||||
s.mux.HandleFunc("/login", s.handleLogin)
|
||||
s.mux.HandleFunc("/logout", s.handleLogout)
|
||||
|
||||
s.mux.HandleFunc("/api/sse", s.sse.HandleSSE)
|
||||
|
||||
s.mux.HandleFunc("/api/queue", s.handleGetQueue)
|
||||
s.mux.HandleFunc("/api/jobs/bump/", s.handleBumpJob)
|
||||
s.mux.HandleFunc("/api/jobs/cancel/", s.handleCancelJob)
|
||||
|
||||
s.mux.HandleFunc("/api/folders", s.handleGetWatchFolders)
|
||||
s.mux.HandleFunc("/api/folders/add", s.handleAddWatchFolder)
|
||||
s.mux.HandleFunc("/api/folders/delete/", s.handleDeleteWatchFolder)
|
||||
s.mux.HandleFunc("/api/logs", s.handleGetLogs)
|
||||
|
||||
// Static files
|
||||
s.mux.Handle("/static/", http.FileServer(http.FS(rootweb.FS)))
|
||||
|
||||
// Pages
|
||||
s.mux.HandleFunc("/", s.handleDashboard)
|
||||
s.mux.HandleFunc("/folders", s.handlePage("folders.html"))
|
||||
s.mux.HandleFunc("/history", s.handleHistory)
|
||||
s.mux.HandleFunc("/logs", s.handlePage("logs.html"))
|
||||
s.mux.HandleFunc("/settings", s.handlePage("settings.html"))
|
||||
}
|
||||
|
||||
func (s *Server) handleGetLogs(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
logs := logger.GetRecentLogs()
|
||||
json.NewEncoder(w).Encode(logs)
|
||||
}
|
||||
|
||||
func formatBytes(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit && bytes > -unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
n := bytes
|
||||
if n < 0 {
|
||||
n = -n
|
||||
}
|
||||
for n >= unit*unit && exp < len("KMGTPE")-1 {
|
||||
div *= unit
|
||||
exp++
|
||||
n /= unit
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
stats, _ := db.GetDashboardStats()
|
||||
|
||||
// Create formatted stats
|
||||
data := struct {
|
||||
AuthEnabled bool
|
||||
FilesEncoded int
|
||||
QueueLength int
|
||||
SavedSpace string
|
||||
}{
|
||||
AuthEnabled: s.cfg.Auth.Username != "",
|
||||
FilesEncoded: stats.FilesEncoded,
|
||||
QueueLength: stats.QueueLength,
|
||||
SavedSpace: formatBytes(stats.TotalSavedSpace),
|
||||
}
|
||||
|
||||
tmpl, err := template.New("layout").Funcs(template.FuncMap{
|
||||
"formatBytes": formatBytes,
|
||||
}).ParseFS(rootweb.FS, "templates/layout.html", "templates/dashboard.html")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
tmpl.ExecuteTemplate(w, "layout", data)
|
||||
}
|
||||
|
||||
func (s *Server) handlePage(tmplName string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tmpl, err := template.ParseFS(rootweb.FS, "templates/layout.html", filepath.Join("templates", tmplName))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := struct{ AuthEnabled bool }{AuthEnabled: s.cfg.Auth.Username != ""}
|
||||
if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
log.Printf("Template execution error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleHistory(w http.ResponseWriter, r *http.Request) {
|
||||
reports, total, err := db.GetJobReports(50, 0) // Basic pagination for now
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := struct {
|
||||
AuthEnabled bool
|
||||
Reports []db.JobReport
|
||||
Total int
|
||||
}{
|
||||
AuthEnabled: s.cfg.Auth.Username != "",
|
||||
Reports: reports,
|
||||
Total: total,
|
||||
}
|
||||
|
||||
tmpl, err := template.New("layout").Funcs(template.FuncMap{
|
||||
"formatBytes": formatBytes,
|
||||
}).ParseFS(rootweb.FS, "templates/layout.html", "templates/history.html")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
log.Printf("Template execution error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SSEMessage struct {
|
||||
Event string
|
||||
Data interface{}
|
||||
}
|
||||
|
||||
type SSEServer struct {
|
||||
clients map[chan SSEMessage]bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewSSEServer() *SSEServer {
|
||||
return &SSEServer{
|
||||
clients: make(map[chan SSEMessage]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SSEServer) HandleSSE(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
clientChan := make(chan SSEMessage)
|
||||
s.mu.Lock()
|
||||
s.clients[clientChan] = true
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.clients, clientChan)
|
||||
s.mu.Unlock()
|
||||
close(clientChan)
|
||||
}()
|
||||
|
||||
notify := r.Context().Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-notify:
|
||||
return
|
||||
case msg := <-clientChan:
|
||||
dataBytes, err := json.Marshal(msg.Data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", msg.Event, string(dataBytes))
|
||||
w.(http.Flusher).Flush()
|
||||
case <-time.After(30 * time.Second):
|
||||
// Keepalive
|
||||
fmt.Fprintf(w, "event: ping\ndata: {}\n\n")
|
||||
w.(http.Flusher).Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SSEServer) Broadcast(event string, data interface{}) {
|
||||
msg := SSEMessage{Event: event, Data: data}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for clientChan := range s.clients {
|
||||
select {
|
||||
case clientChan <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed static/* templates/*
|
||||
var FS embed.FS
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,263 @@
|
||||
:root {
|
||||
--bg: #0a0a0a;
|
||||
--surface: #141414;
|
||||
--surface-hover: #1f1f1f;
|
||||
--border: #2a2a2a;
|
||||
--text-primary: #f0f0f0;
|
||||
--text-secondary: #888888;
|
||||
--accent: #21D198;
|
||||
--accent-hover: #1cb582;
|
||||
--success: #21D198;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
--font: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--bg);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Navbar */
|
||||
.navbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 2rem;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.navbar-brand span {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.nav-links a:hover, .nav-links a.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.container {
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
margin-top: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
border: 1px solid var(--accent);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
box-shadow: 0 0 10px rgba(33, 209, 152, 0.3);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: transparent;
|
||||
color: var(--error);
|
||||
border: 1px solid var(--error);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: var(--error);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.badge.processing { background: rgba(59, 130, 246, 0.1); color: #60a5fa; border-color: rgba(59, 130, 246, 0.3); }
|
||||
.badge.pending { background: rgba(245, 158, 11, 0.1); color: var(--warning); border-color: rgba(245, 158, 11, 0.3); }
|
||||
.badge.success { background: rgba(33, 209, 152, 0.1); color: var(--success); border-color: rgba(33, 209, 152, 0.3); }
|
||||
.badge.failed { background: rgba(239, 68, 68, 0.1); color: var(--error); border-color: rgba(239, 68, 68, 0.3); }
|
||||
.badge.skipped { background: rgba(136, 136, 136, 0.1); color: var(--text-secondary); border-color: rgba(136, 136, 136, 0.3); }
|
||||
|
||||
/* Progress bar */
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: var(--surface-hover);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
width: 0%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.navbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
margin-top: 1rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav-links a[href="/logout"] {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
table {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.75rem 0.5rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start !important;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
{{define "content"}}
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
|
||||
<div class="card" style="text-align: center; padding: 1.5rem;">
|
||||
<h3 style="margin-top: 0; color: var(--text-secondary); font-size: 0.875rem; text-transform: uppercase;">Files Encoded</h3>
|
||||
<div style="font-size: 2rem; font-weight: 700; color: var(--accent);">{{.FilesEncoded}}</div>
|
||||
</div>
|
||||
<div class="card" style="text-align: center; padding: 1.5rem;">
|
||||
<h3 style="margin-top: 0; color: var(--text-secondary); font-size: 0.875rem; text-transform: uppercase;">Space Saved</h3>
|
||||
<div style="font-size: 2rem; font-weight: 700; color: var(--accent);">{{.SavedSpace}}</div>
|
||||
</div>
|
||||
<div class="card" style="text-align: center; padding: 1.5rem;">
|
||||
<h3 style="margin-top: 0; color: var(--text-secondary); font-size: 0.875rem; text-transform: uppercase;">In Queue</h3>
|
||||
<div style="font-size: 2rem; font-weight: 700; color: var(--accent);">{{.QueueLength}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Active Job Queue</h2>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>File</th>
|
||||
<th>Type</th>
|
||||
<th>Priority</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="queue-body">
|
||||
<!-- Populated by JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const queueBody = document.getElementById('queue-body');
|
||||
|
||||
async function loadQueue() {
|
||||
const res = await fetch('/api/queue');
|
||||
const jobs = await res.json();
|
||||
renderQueue(jobs || []);
|
||||
}
|
||||
|
||||
function renderQueue(jobs) {
|
||||
queueBody.innerHTML = jobs.map(job => {
|
||||
let statusBadge = `<span class="badge ${job.status}">${job.status}</span>`;
|
||||
|
||||
let progressHtml = '';
|
||||
if (job.status === 'processing') {
|
||||
progressHtml = `
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="progress-${job.id}" style="width: 0%"></div>
|
||||
</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.25rem;" id="progress-text-${job.id}">0%</div>
|
||||
`;
|
||||
}
|
||||
|
||||
let actions = '';
|
||||
if (job.status === 'pending') {
|
||||
actions = `
|
||||
<button class="btn btn-sm btn-outline" onclick="bumpJob(${job.id})">Bump</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="cancelJob(${job.id})">Cancel</button>
|
||||
`;
|
||||
} else if (job.status === 'processing') {
|
||||
actions = `
|
||||
<button class="btn btn-sm btn-danger" onclick="cancelJob(${job.id})">Cancel</button>
|
||||
`;
|
||||
} else if (job.status === 'failed') {
|
||||
// Retry not fully implemented in API yet, but could re-enqueue
|
||||
actions = `<span style="color: var(--error); font-size: 0.75rem;">${job.error_message}</span>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<tr id="job-${job.id}">
|
||||
<td>${job.id}</td>
|
||||
<td style="word-break: break-all;">
|
||||
${job.file_path.split('/').pop()}
|
||||
${progressHtml}
|
||||
</td>
|
||||
<td><span class="badge" style="background: var(--surface-hover); color: var(--text-primary); border-color: var(--border);">${job.media_type}</span></td>
|
||||
<td>${job.priority}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${actions}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function bumpJob(id) {
|
||||
await fetch('/api/jobs/bump/' + id, { method: 'POST' });
|
||||
loadQueue();
|
||||
}
|
||||
|
||||
async function cancelJob(id) {
|
||||
if (!confirm("Cancel job?")) return;
|
||||
await fetch('/api/jobs/cancel/' + id, { method: 'POST' });
|
||||
loadQueue();
|
||||
}
|
||||
|
||||
// SSE listeners
|
||||
window.sse.addEventListener('queue_updated', () => loadQueue());
|
||||
window.sse.addEventListener('job_started', () => loadQueue());
|
||||
window.sse.addEventListener('job_completed', () => loadQueue());
|
||||
window.sse.addEventListener('job_failed', () => loadQueue());
|
||||
window.sse.addEventListener('job_added', () => loadQueue());
|
||||
|
||||
window.sse.addEventListener('progress', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
const fill = document.getElementById(`progress-${data.id}`);
|
||||
const text = document.getElementById(`progress-text-${data.id}`);
|
||||
if (fill && text) {
|
||||
fill.style.width = `${data.progress}%`;
|
||||
text.innerText = `${data.progress}%`;
|
||||
}
|
||||
});
|
||||
|
||||
// Initial load
|
||||
loadQueue();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,152 @@
|
||||
{{define "content"}}
|
||||
<div style="display: flex; gap: 2rem; align-items: flex-start; flex-wrap: wrap;">
|
||||
<div class="card" style="flex: 2; min-width: 600px;">
|
||||
<h2>Configured Watch Folders</h2>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Path</th>
|
||||
<th>Type</th>
|
||||
<th>Resolution</th>
|
||||
<th>Flags</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="folders-body">
|
||||
<!-- Populated by JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="flex: 1; min-width: 300px;">
|
||||
<h2>Add Watch Folder</h2>
|
||||
<form id="add-folder-form" onsubmit="addFolder(event)">
|
||||
<div class="form-group">
|
||||
<label>Folder Path</label>
|
||||
<input type="text" id="folder_path" class="form-control" placeholder="/media/movies" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Media Type</label>
|
||||
<select id="media_type" class="form-control" required>
|
||||
<option value="video">Video</option>
|
||||
<option value="audio">Audio</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Target Resolution (Video only)</label>
|
||||
<select id="target_resolution" class="form-control">
|
||||
<option value="">Original</option>
|
||||
<option value="1080p">1080p</option>
|
||||
<option value="720p">720p</option>
|
||||
<option value="480p">480p</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="video_crf_group">
|
||||
<label>CRF (Video only, 0-51)</label>
|
||||
<input type="number" id="crf" class="form-control" placeholder="22" min="0" max="51">
|
||||
</div>
|
||||
<div class="form-group" id="video_preset_group">
|
||||
<label>Preset (Video only)</label>
|
||||
<select id="preset" class="form-control">
|
||||
<option value="">Default (medium)</option>
|
||||
<option value="ultrafast">ultrafast</option>
|
||||
<option value="superfast">superfast</option>
|
||||
<option value="veryfast">veryfast</option>
|
||||
<option value="faster">faster</option>
|
||||
<option value="fast">fast</option>
|
||||
<option value="medium">medium</option>
|
||||
<option value="slow">slow</option>
|
||||
<option value="slower">slower</option>
|
||||
<option value="veryslow">veryslow</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Custom FFmpeg Flags (Optional)</label>
|
||||
<input type="text" id="custom_ffmpeg_flags" class="form-control" placeholder="-vcodec copy">
|
||||
</div>
|
||||
<button type="submit" class="btn" style="width: 100%;">Add Folder</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const foldersBody = document.getElementById('folders-body');
|
||||
|
||||
async function loadFolders() {
|
||||
const res = await fetch('/api/folders');
|
||||
const folders = await res.json();
|
||||
renderFolders(folders || []);
|
||||
}
|
||||
|
||||
function renderFolders(folders) {
|
||||
foldersBody.innerHTML = folders.map(f => `
|
||||
<tr>
|
||||
<td style="word-break: break-all;">${f.folder_path}</td>
|
||||
<td><span class="badge" style="background: var(--surface-hover); color: var(--text-primary); border-color: var(--border);">${f.media_type}</span></td>
|
||||
<td>${f.target_resolution || '-'}</td>
|
||||
<td><code style="background: var(--bg); padding: 0.2rem; border-radius: 4px;">${f.custom_ffmpeg_flags || '-'}</code></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteFolder(${f.id})">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function addFolder(e) {
|
||||
e.preventDefault();
|
||||
|
||||
let flags = document.getElementById('custom_ffmpeg_flags').value;
|
||||
const crf = document.getElementById('crf').value;
|
||||
const preset = document.getElementById('preset').value;
|
||||
const mediaType = document.getElementById('media_type').value;
|
||||
|
||||
if (mediaType === 'video') {
|
||||
if (crf) {
|
||||
flags += (flags ? ' ' : '') + '-crf ' + crf;
|
||||
}
|
||||
if (preset) {
|
||||
flags += (flags ? ' ' : '') + '-preset ' + preset;
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
folder_path: document.getElementById('folder_path').value,
|
||||
media_type: mediaType,
|
||||
target_resolution: document.getElementById('target_resolution').value,
|
||||
custom_ffmpeg_flags: flags,
|
||||
};
|
||||
const res = await fetch('/api/folders/add', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (res.ok) {
|
||||
document.getElementById('add-folder-form').reset();
|
||||
loadFolders();
|
||||
} else {
|
||||
alert('Failed to add folder');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFolder(id) {
|
||||
if (!confirm('Delete watch folder?')) return;
|
||||
const res = await fetch('/api/folders/delete/' + id, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
loadFolders();
|
||||
} else {
|
||||
alert('Failed to delete folder');
|
||||
}
|
||||
}
|
||||
|
||||
loadFolders();
|
||||
|
||||
document.getElementById('media_type').addEventListener('change', function (e) {
|
||||
const isVideo = e.target.value === 'video';
|
||||
document.getElementById('target_resolution').parentElement.style.display = isVideo ? 'block' : 'none';
|
||||
document.getElementById('video_crf_group').style.display = isVideo ? 'block' : 'none';
|
||||
document.getElementById('video_preset_group').style.display = isVideo ? 'block' : 'none';
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,80 @@
|
||||
{{define "content"}}
|
||||
<div class="card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem; margin-bottom: 1rem;">
|
||||
<h2 style="margin: 0;">History</h2>
|
||||
<div style="display: flex; align-items: center; gap: 1rem;">
|
||||
<input type="text" id="history-search" class="form-control" placeholder="Search history..." onkeyup="filterHistory()" style="max-width: 300px;">
|
||||
<span style="color: var(--text-secondary); font-size: 0.875rem; white-space: nowrap;">Total Records: <span id="record-count">{{.Total}}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>Original Size</th>
|
||||
<th>Encoded Size</th>
|
||||
<th>Saved</th>
|
||||
<th>Time</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Reports}}
|
||||
<tr>
|
||||
<td style="word-break: break-all;">
|
||||
<div style="font-weight: 500;">{{.FilePath}}</div>
|
||||
{{if .ErrorMessage}}
|
||||
<div style="color: var(--error); font-size: 0.75rem; margin-top: 0.25rem;">{{.ErrorMessage}}</div>
|
||||
{{end}}
|
||||
</td>
|
||||
<td><span class="badge" style="background: var(--surface-hover); color: var(--text-primary); border-color: var(--border);">{{.MediaType}}</span></td>
|
||||
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
||||
<td>{{formatBytes .OriginalSize}}</td>
|
||||
<td>{{formatBytes .EncodedSize}}</td>
|
||||
<td style="color: var(--accent);">{{formatBytes .SizeSaved}}</td>
|
||||
<td>{{.ProcessingTime}}s</td>
|
||||
<td>{{.CreatedAt.Format "Jan 02, 15:04:05"}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="8" style="text-align: center; color: var(--text-secondary);">No job reports found.</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function filterHistory() {
|
||||
var input, filter, table, tr, td, i, txtValue;
|
||||
input = document.getElementById("history-search");
|
||||
filter = input.value.toUpperCase();
|
||||
table = document.querySelector(".table-container table");
|
||||
tr = table.getElementsByTagName("tr");
|
||||
var visibleCount = 0;
|
||||
|
||||
for (i = 1; i < tr.length; i++) { // Skip header row
|
||||
// If it's the "No job reports found" row, skip it
|
||||
if (tr[i].cells.length === 1) continue;
|
||||
|
||||
td = tr[i].getElementsByTagName("td")[0]; // File column
|
||||
if (td) {
|
||||
txtValue = td.textContent || td.innerText;
|
||||
if (txtValue.toUpperCase().indexOf(filter) > -1) {
|
||||
tr[i].style.display = "";
|
||||
visibleCount++;
|
||||
} else {
|
||||
tr[i].style.display = "none";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the record count
|
||||
document.getElementById("record-count").textContent = filter ? visibleCount : "{{.Total}}";
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,52 @@
|
||||
{{define "layout"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GoEncode</title>
|
||||
<link rel="icon" type="image/png" href="/static/icon.png">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script>
|
||||
// Global SSE listener setup early so components can attach to it
|
||||
const sse = new EventSource('/api/sse');
|
||||
window.sse = sse;
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (window.sse) {
|
||||
window.sse.close();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="navbar-brand" style="display: flex; align-items: center;">
|
||||
<img src="/static/icon.png" alt="" style="height: 1.5rem; margin-right: 0.5rem; border-radius: 4px;">
|
||||
Go<span>Encode</span>
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a href="/" id="nav-dashboard">Dashboard</a>
|
||||
<a href="/folders" id="nav-folders">Watch Folders</a>
|
||||
<a href="/history" id="nav-history">History</a>
|
||||
<a href="/logs" id="nav-logs">Logs</a>
|
||||
{{if .AuthEnabled}}
|
||||
<a href="/logout" style="margin-left: 2rem; color: #ff4444; border-color: #ff4444;">Sign Out</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container">
|
||||
{{template "content" .}}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set active nav link
|
||||
const path = window.location.pathname;
|
||||
if (path === "/") document.getElementById("nav-dashboard").classList.add("active");
|
||||
else if (path.startsWith("/folders")) document.getElementById("nav-folders").classList.add("active");
|
||||
else if (path.startsWith("/history")) document.getElementById("nav-history").classList.add("active");
|
||||
else if (path.startsWith("/logs")) document.getElementById("nav-logs").classList.add("active");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,140 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GoEncode - Login</title>
|
||||
<link rel="icon" type="image/png" href="/static/icon.png">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a0a;
|
||||
--surface: #141414;
|
||||
--border: #2a2a2a;
|
||||
--text-primary: #f0f0f0;
|
||||
--text-secondary: #888888;
|
||||
--accent: #21D198;
|
||||
--accent-hover: #1cb582;
|
||||
--error: #ef4444;
|
||||
--font: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--bg);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 2.5rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.5);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 2rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
h1 span {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
border: 1px solid var(--accent);
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
margin-top: 1rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
box-shadow: 0 0 10px rgba(33, 209, 152, 0.3);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--error);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-card">
|
||||
<div style="display: flex; align-items: center; justify-content: center; margin-bottom: 2rem;">
|
||||
<img src="/static/icon.png" alt="" style="height: 2.5rem; margin-right: 1rem; border-radius: 4px;" onerror="this.style.display='none'">
|
||||
<h1 style="margin-bottom: 0;">Go<span>Encode</span></h1>
|
||||
</div>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/login">
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" name="username" required autofocus autocomplete="username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Password</label>
|
||||
<input type="password" name="password" required autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit" class="btn">Sign In</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,60 @@
|
||||
{{define "content"}}
|
||||
<div class="header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
||||
<div>
|
||||
<h2 style="margin: 0;">System Logs</h2>
|
||||
<p style="color: var(--text-secondary); margin-top: 0.5rem;">Live streaming output of the application logs.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding: 0;">
|
||||
<pre id="log-container" style="margin: 0; padding: 1.5rem; background: var(--surface); color: var(--text-primary); border-radius: 12px; font-family: monospace; height: 600px; overflow-y: auto; white-space: pre-wrap; word-wrap: break-word; font-size: 0.875rem; line-height: 1.5;"></pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const logContainer = document.getElementById('log-container');
|
||||
let autoScroll = true;
|
||||
|
||||
logContainer.addEventListener('scroll', () => {
|
||||
const isAtBottom = logContainer.scrollHeight - logContainer.scrollTop <= logContainer.clientHeight + 50;
|
||||
autoScroll = isAtBottom;
|
||||
});
|
||||
|
||||
function appendLog(timestamp, message) {
|
||||
const entry = document.createElement('div');
|
||||
const tsSpan = document.createElement('span');
|
||||
tsSpan.style.color = '#888';
|
||||
tsSpan.textContent = `[${timestamp}] `;
|
||||
|
||||
const msgSpan = document.createElement('span');
|
||||
msgSpan.textContent = message;
|
||||
|
||||
entry.appendChild(tsSpan);
|
||||
entry.appendChild(msgSpan);
|
||||
logContainer.appendChild(entry);
|
||||
|
||||
if (autoScroll) {
|
||||
logContainer.scrollTop = logContainer.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// Load recent logs first
|
||||
fetch('/api/logs')
|
||||
.then(response => response.json())
|
||||
.then(logs => {
|
||||
if (logs && logs.length > 0) {
|
||||
logs.forEach(log => {
|
||||
appendLog(log.timestamp, log.message);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error fetching logs:', error));
|
||||
|
||||
// Listen for live updates
|
||||
if (window.sse) {
|
||||
window.sse.addEventListener('log', function(e) {
|
||||
const data = JSON.parse(e.data);
|
||||
appendLog(data.timestamp, data.message);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,21 @@
|
||||
{{define "content"}}
|
||||
<div class="card">
|
||||
<h2>Global Settings</h2>
|
||||
<p style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 2rem;">
|
||||
Database and core server configurations are managed via <code>goencode.yaml</code>.
|
||||
Application preferences can be configured below.
|
||||
</p>
|
||||
|
||||
<form onsubmit="event.preventDefault(); alert('Settings saved');">
|
||||
<div class="form-group" style="max-width: 400px;">
|
||||
<label>Default Video CRF (Quality)</label>
|
||||
<input type="number" class="form-control" value="22" min="0" max="51">
|
||||
</div>
|
||||
<div class="form-group" style="max-width: 400px;">
|
||||
<label>Default Audio Bitrate (kbps)</label>
|
||||
<input type="number" class="form-control" value="320" step="32" min="64" max="320">
|
||||
</div>
|
||||
<button type="submit" class="btn">Save Preferences</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user