feat: initialise project structure with core encoding queue, database models, and web management API
Build / Build and Push (push) Successful in 50s

This commit is contained in:
2026-06-05 19:53:31 +01:00
parent dceba72891
commit 07aa8e5ffe
33 changed files with 2911 additions and 1 deletions
+8
View File
@@ -0,0 +1,8 @@
media/
db/
bin/
*.mkv
*.mp4
*.mp3
.git/
.gitea/
+21
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
media/
+33
View File
@@ -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"]
+106 -1
View File
@@ -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
```
+60
View File
@@ -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...")
}
+25
View File
@@ -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:
+9
View File
@@ -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
)
+21
View File
@@ -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"
+99
View File
@@ -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
}
+92
View File
@@ -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
}
+45
View File
@@ -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"`
}
+182
View File
@@ -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
}
+45
View File
@@ -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
}
+133
View File
@@ -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
}
+75
View File
@@ -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
}
+90
View File
@@ -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
}
+58
View File
@@ -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)
}
}
+265
View File
@@ -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)
}
+222
View File
@@ -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()
}
+103
View File
@@ -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)
}
+246
View File
@@ -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)
}
}
+75
View File
@@ -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:
}
}
}
+6
View File
@@ -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

+263
View File
@@ -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;
}
}
+123
View File
@@ -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}}
+152
View File
@@ -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}}
+80
View File
@@ -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}}
+52
View File
@@ -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}}
+140
View File
@@ -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>
+60
View File
@@ -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}}
+21
View File
@@ -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}}