diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..3bc6a4d
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,8 @@
+media/
+db/
+bin/
+*.mkv
+*.mp4
+*.mp3
+.git/
+.gitea/
\ No newline at end of file
diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml
new file mode 100644
index 0000000..9a4b46f
--- /dev/null
+++ b/.gitea/workflows/build.yml
@@ -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
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c59768b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+media/
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..3131264
--- /dev/null
+++ b/Dockerfile
@@ -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"]
diff --git a/README.md b/README.md
index cbf48d2..7e201cf 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,106 @@
-# GoEncode
\ No newline at end of file
+
+
+
+# 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.
+
+
+
+## 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
+```
\ No newline at end of file
diff --git a/cmd/goencode/main.go b/cmd/goencode/main.go
new file mode 100644
index 0000000..f6cf733
--- /dev/null
+++ b/cmd/goencode/main.go
@@ -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...")
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..348f608
--- /dev/null
+++ b/docker-compose.yml
@@ -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:
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..bb02ed3
--- /dev/null
+++ b/go.mod
@@ -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
+)
diff --git a/goencode.yaml b/goencode.yaml
new file mode 100644
index 0000000..fbc3cba
--- /dev/null
+++ b/goencode.yaml
@@ -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"
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..d16ddda
--- /dev/null
+++ b/internal/config/config.go
@@ -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
+}
diff --git a/internal/db/db.go b/internal/db/db.go
new file mode 100644
index 0000000..aae3c65
--- /dev/null
+++ b/internal/db/db.go
@@ -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
+}
diff --git a/internal/db/models.go b/internal/db/models.go
new file mode 100644
index 0000000..cbdf8c6
--- /dev/null
+++ b/internal/db/models.go
@@ -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"`
+}
diff --git a/internal/db/queries.go b/internal/db/queries.go
new file mode 100644
index 0000000..af99855
--- /dev/null
+++ b/internal/db/queries.go
@@ -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
+}
diff --git a/internal/encoder/audio.go b/internal/encoder/audio.go
new file mode 100644
index 0000000..8cf1d41
--- /dev/null
+++ b/internal/encoder/audio.go
@@ -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
+}
diff --git a/internal/encoder/ffmpeg.go b/internal/encoder/ffmpeg.go
new file mode 100644
index 0000000..6f0d2a4
--- /dev/null
+++ b/internal/encoder/ffmpeg.go
@@ -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
+}
diff --git a/internal/encoder/video.go b/internal/encoder/video.go
new file mode 100644
index 0000000..0978bcd
--- /dev/null
+++ b/internal/encoder/video.go
@@ -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
+}
diff --git a/internal/logger/logger.go b/internal/logger/logger.go
new file mode 100644
index 0000000..0b5f776
--- /dev/null
+++ b/internal/logger/logger.go
@@ -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
+}
diff --git a/internal/queue/manager.go b/internal/queue/manager.go
new file mode 100644
index 0000000..a570ba6
--- /dev/null
+++ b/internal/queue/manager.go
@@ -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)
+ }
+}
diff --git a/internal/queue/worker.go b/internal/queue/worker.go
new file mode 100644
index 0000000..e842bcd
--- /dev/null
+++ b/internal/queue/worker.go
@@ -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)
+}
diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go
new file mode 100644
index 0000000..03b28ac
--- /dev/null
+++ b/internal/watcher/watcher.go
@@ -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()
+}
diff --git a/internal/web/api.go b/internal/web/api.go
new file mode 100644
index 0000000..93e1b4e
--- /dev/null
+++ b/internal/web/api.go
@@ -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)
+}
diff --git a/internal/web/server.go b/internal/web/server.go
new file mode 100644
index 0000000..d759701
--- /dev/null
+++ b/internal/web/server.go
@@ -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)
+ }
+}
diff --git a/internal/web/sse.go b/internal/web/sse.go
new file mode 100644
index 0000000..3652186
--- /dev/null
+++ b/internal/web/sse.go
@@ -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:
+ }
+ }
+}
diff --git a/web/fs.go b/web/fs.go
new file mode 100644
index 0000000..ef8f6e7
--- /dev/null
+++ b/web/fs.go
@@ -0,0 +1,6 @@
+package web
+
+import "embed"
+
+//go:embed static/* templates/*
+var FS embed.FS
diff --git a/web/static/icon.png b/web/static/icon.png
new file mode 100644
index 0000000..bb4789f
Binary files /dev/null and b/web/static/icon.png differ
diff --git a/web/static/style.css b/web/static/style.css
new file mode 100644
index 0000000..ae419f9
--- /dev/null
+++ b/web/static/style.css
@@ -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;
+ }
+}
diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html
new file mode 100644
index 0000000..afaa0d7
--- /dev/null
+++ b/web/templates/dashboard.html
@@ -0,0 +1,123 @@
+{{define "content"}}
+
+
+
Files Encoded
+
{{.FilesEncoded}}
+
+
+
Space Saved
+
{{.SavedSpace}}
+
+
+
In Queue
+
{{.QueueLength}}
+
+
+
+
+
Active Job Queue
+
+
+
+
+ ID
+ File
+ Type
+ Priority
+ Status
+ Actions
+
+
+
+
+
+
+
+
+
+
+{{end}}
diff --git a/web/templates/folders.html b/web/templates/folders.html
new file mode 100644
index 0000000..0c63a63
--- /dev/null
+++ b/web/templates/folders.html
@@ -0,0 +1,152 @@
+{{define "content"}}
+
+
+
Configured Watch Folders
+
+
+
+
+ Path
+ Type
+ Resolution
+ Flags
+ Actions
+
+
+
+
+
+
+
+
+
+
+
+
+
+{{end}}
\ No newline at end of file
diff --git a/web/templates/history.html b/web/templates/history.html
new file mode 100644
index 0000000..3732839
--- /dev/null
+++ b/web/templates/history.html
@@ -0,0 +1,80 @@
+{{define "content"}}
+
+
+
History
+
+
+ Total Records: {{.Total}}
+
+
+
+
+
+
+ File
+ Type
+ Status
+ Original Size
+ Encoded Size
+ Saved
+ Time
+ Date
+
+
+
+ {{range .Reports}}
+
+
+ {{.FilePath}}
+ {{if .ErrorMessage}}
+ {{.ErrorMessage}}
+ {{end}}
+
+ {{.MediaType}}
+ {{.Status}}
+ {{formatBytes .OriginalSize}}
+ {{formatBytes .EncodedSize}}
+ {{formatBytes .SizeSaved}}
+ {{.ProcessingTime}}s
+ {{.CreatedAt.Format "Jan 02, 15:04:05"}}
+
+ {{else}}
+
+ No job reports found.
+
+ {{end}}
+
+
+
+
+
+
+{{end}}
diff --git a/web/templates/layout.html b/web/templates/layout.html
new file mode 100644
index 0000000..741bd16
--- /dev/null
+++ b/web/templates/layout.html
@@ -0,0 +1,52 @@
+{{define "layout"}}
+
+
+
+
+
+ GoEncode
+
+
+
+
+
+
+
+
+ Go
Encode
+
+
+
+
+ {{template "content" .}}
+
+
+
+
+
+{{end}}
diff --git a/web/templates/login.html b/web/templates/login.html
new file mode 100644
index 0000000..c61474e
--- /dev/null
+++ b/web/templates/login.html
@@ -0,0 +1,140 @@
+
+
+
+
+
+ GoEncode - Login
+
+
+
+
+
+
+
+
+
GoEncode
+
+
+ {{if .Error}}
+
{{.Error}}
+ {{end}}
+
+
+
+
+
diff --git a/web/templates/logs.html b/web/templates/logs.html
new file mode 100644
index 0000000..53a8394
--- /dev/null
+++ b/web/templates/logs.html
@@ -0,0 +1,60 @@
+{{define "content"}}
+
+
+
+
+
+{{end}}
diff --git a/web/templates/settings.html b/web/templates/settings.html
new file mode 100644
index 0000000..2d0ce6f
--- /dev/null
+++ b/web/templates/settings.html
@@ -0,0 +1,21 @@
+{{define "content"}}
+
+
Global Settings
+
+ Database and core server configurations are managed via goencode.yaml.
+ Application preferences can be configured below.
+
+
+
+
+{{end}}
\ No newline at end of file