feat: initialise project structure with core encoding queue, database models, and web management API
Build / Build and Push (push) Successful in 50s
Build / Build and Push (push) Successful in 50s
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func getEnvStr(key, defaultVal string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func getEnvInt(key string, defaultVal int) int {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
if i, err := strconv.Atoi(val); err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `yaml:"port"`
|
||||
ListenAddr string `yaml:"listen_addr"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
User string `yaml:"user"`
|
||||
Password string `yaml:"password"`
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
|
||||
type EncoderConfig struct {
|
||||
FFmpegPath string `yaml:"ffmpeg_path"`
|
||||
TempDir string `yaml:"temp_dir"`
|
||||
}
|
||||
|
||||
type LoggingConfig struct {
|
||||
Level string `yaml:"level"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Auth AuthConfig `yaml:"auth"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Encoder EncoderConfig `yaml:"encoder"`
|
||||
Logging LoggingConfig `yaml:"logging"`
|
||||
}
|
||||
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
var cfg Config
|
||||
|
||||
// Try reading from file first (it's okay if it doesn't exist for env-only deployments)
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("could not parse config file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Override with env variables
|
||||
cfg.Server.Port = getEnvInt("GOENCODE_PORT", cfg.Server.Port)
|
||||
cfg.Server.ListenAddr = getEnvStr("GOENCODE_LISTEN_ADDR", cfg.Server.ListenAddr)
|
||||
cfg.Auth.Username = getEnvStr("GOENCODE_AUTH_USER", cfg.Auth.Username)
|
||||
cfg.Auth.Password = getEnvStr("GOENCODE_AUTH_PASS", cfg.Auth.Password)
|
||||
cfg.Database.Host = getEnvStr("GOENCODE_DB_HOST", cfg.Database.Host)
|
||||
cfg.Database.Port = getEnvInt("GOENCODE_DB_PORT", cfg.Database.Port)
|
||||
cfg.Database.User = getEnvStr("GOENCODE_DB_USER", cfg.Database.User)
|
||||
cfg.Database.Password = getEnvStr("GOENCODE_DB_PASS", cfg.Database.Password)
|
||||
cfg.Database.Name = getEnvStr("GOENCODE_DB_NAME", cfg.Database.Name)
|
||||
cfg.Encoder.FFmpegPath = getEnvStr("GOENCODE_FFMPEG_PATH", cfg.Encoder.FFmpegPath)
|
||||
cfg.Encoder.TempDir = getEnvStr("GOENCODE_TEMP_DIR", cfg.Encoder.TempDir)
|
||||
cfg.Logging.Level = getEnvStr("GOENCODE_LOG_LEVEL", cfg.Logging.Level)
|
||||
|
||||
// Defaults if missing entirely
|
||||
if cfg.Server.Port == 0 {
|
||||
cfg.Server.Port = 8080
|
||||
}
|
||||
if cfg.Server.ListenAddr == "" {
|
||||
cfg.Server.ListenAddr = "0.0.0.0"
|
||||
}
|
||||
if cfg.Database.Port == 0 {
|
||||
cfg.Database.Port = 3306
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"goencode/internal/config"
|
||||
)
|
||||
|
||||
var DB *sql.DB
|
||||
|
||||
func Init(cfg *config.DatabaseConfig) error {
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true",
|
||||
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Name)
|
||||
|
||||
var err error
|
||||
DB, err = sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set connection pool limits
|
||||
DB.SetMaxOpenConns(25)
|
||||
DB.SetMaxIdleConns(25)
|
||||
DB.SetConnMaxLifetime(5 * time.Minute)
|
||||
|
||||
if err = DB.Ping(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Println("Connected to MariaDB successfully")
|
||||
|
||||
return runMigrations()
|
||||
}
|
||||
|
||||
func runMigrations() error {
|
||||
queries := []string{
|
||||
`CREATE TABLE IF NOT EXISTS watch_folders (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
folder_path VARCHAR(500) NOT NULL UNIQUE,
|
||||
media_type ENUM('video', 'audio') NOT NULL,
|
||||
target_resolution VARCHAR(20),
|
||||
custom_ffmpeg_flags TEXT,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS jobs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
file_path VARCHAR(500) NOT NULL,
|
||||
media_type ENUM('video', 'audio') NOT NULL,
|
||||
status ENUM('pending', 'processing', 'failed') NOT NULL DEFAULT 'pending',
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
original_size BIGINT DEFAULT 0,
|
||||
target_resolution VARCHAR(20),
|
||||
ffmpeg_flags TEXT,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS job_reports (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
file_path VARCHAR(500) NOT NULL,
|
||||
media_type ENUM('video', 'audio') NOT NULL,
|
||||
status ENUM('success', 'failed', 'skipped') NOT NULL,
|
||||
original_size BIGINT NOT NULL DEFAULT 0,
|
||||
encoded_size BIGINT NOT NULL DEFAULT 0,
|
||||
size_saved BIGINT NOT NULL DEFAULT 0,
|
||||
processing_time DECIMAL(10,2) DEFAULT 0,
|
||||
target_resolution VARCHAR(20),
|
||||
ffmpeg_flags TEXT,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS app_config (
|
||||
config_key VARCHAR(100) PRIMARY KEY,
|
||||
config_value TEXT
|
||||
)`,
|
||||
}
|
||||
|
||||
for _, q := range queries {
|
||||
if _, err := DB.Exec(q); err != nil {
|
||||
return fmt.Errorf("migration failed: %w\nQuery: %s", err, q)
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("Database schemas initialized")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type WatchFolder struct {
|
||||
ID int `json:"id"`
|
||||
FolderPath string `json:"folder_path"`
|
||||
MediaType string `json:"media_type"`
|
||||
TargetResolution string `json:"target_resolution"`
|
||||
CustomFFmpegFlags string `json:"custom_ffmpeg_flags"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
ID int `json:"id"`
|
||||
FilePath string `json:"file_path"`
|
||||
MediaType string `json:"media_type"`
|
||||
Status string `json:"status"` // pending, processing, failed
|
||||
Priority int `json:"priority"`
|
||||
OriginalSize int64 `json:"original_size"`
|
||||
TargetResolution string `json:"target_resolution"`
|
||||
FFmpegFlags string `json:"ffmpeg_flags"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type JobReport struct {
|
||||
ID int `json:"id"`
|
||||
FilePath string `json:"file_path"`
|
||||
MediaType string `json:"media_type"`
|
||||
Status string `json:"status"` // success, failed, skipped
|
||||
OriginalSize int64 `json:"original_size"`
|
||||
EncodedSize int64 `json:"encoded_size"`
|
||||
SizeSaved int64 `json:"size_saved"`
|
||||
ProcessingTime float64 `json:"processing_time"`
|
||||
TargetResolution string `json:"target_resolution"`
|
||||
FFmpegFlags string `json:"ffmpeg_flags"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// Watch Folders
|
||||
|
||||
func GetWatchFolders() ([]WatchFolder, error) {
|
||||
rows, err := DB.Query(`SELECT id, folder_path, media_type, target_resolution, custom_ffmpeg_flags, enabled, created_at, updated_at FROM watch_folders`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var folders []WatchFolder
|
||||
for rows.Next() {
|
||||
var f WatchFolder
|
||||
var targetRes, ffmpegFlags sql.NullString
|
||||
if err := rows.Scan(&f.ID, &f.FolderPath, &f.MediaType, &targetRes, &ffmpegFlags, &f.Enabled, &f.CreatedAt, &f.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if targetRes.Valid {
|
||||
f.TargetResolution = targetRes.String
|
||||
}
|
||||
if ffmpegFlags.Valid {
|
||||
f.CustomFFmpegFlags = ffmpegFlags.String
|
||||
}
|
||||
folders = append(folders, f)
|
||||
}
|
||||
return folders, nil
|
||||
}
|
||||
|
||||
func AddWatchFolder(f WatchFolder) error {
|
||||
_, err := DB.Exec(`INSERT INTO watch_folders (folder_path, media_type, target_resolution, custom_ffmpeg_flags, enabled) VALUES (?, ?, ?, ?, ?)`,
|
||||
f.FolderPath, f.MediaType, nullStr(f.TargetResolution), nullStr(f.CustomFFmpegFlags), f.Enabled)
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteWatchFolder(id int) error {
|
||||
_, err := DB.Exec(`DELETE FROM watch_folders WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func nullStr(s string) sql.NullString {
|
||||
if s == "" {
|
||||
return sql.NullString{Valid: false}
|
||||
}
|
||||
return sql.NullString{String: s, Valid: true}
|
||||
}
|
||||
|
||||
// Jobs
|
||||
|
||||
func AddJob(filePath, mediaType string, priority int, targetRes, ffmpegFlags string, originalSize int64) error {
|
||||
_, err := DB.Exec(`INSERT INTO jobs (file_path, media_type, priority, target_resolution, ffmpeg_flags, original_size) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
filePath, mediaType, priority, nullStr(targetRes), nullStr(ffmpegFlags), originalSize)
|
||||
return err
|
||||
}
|
||||
|
||||
func GetPendingJobs() ([]Job, error) {
|
||||
rows, err := DB.Query(`SELECT id, file_path, media_type, status, priority, original_size, target_resolution, ffmpeg_flags, error_message, created_at, updated_at FROM jobs ORDER BY priority DESC, created_at ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var jobs []Job
|
||||
for rows.Next() {
|
||||
var j Job
|
||||
var targetRes, ffmpegFlags, errMsg sql.NullString
|
||||
if err := rows.Scan(&j.ID, &j.FilePath, &j.MediaType, &j.Status, &j.Priority, &j.OriginalSize, &targetRes, &ffmpegFlags, &errMsg, &j.CreatedAt, &j.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if targetRes.Valid { j.TargetResolution = targetRes.String }
|
||||
if ffmpegFlags.Valid { j.FFmpegFlags = ffmpegFlags.String }
|
||||
if errMsg.Valid { j.ErrorMessage = errMsg.String }
|
||||
jobs = append(jobs, j)
|
||||
}
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
func UpdateJobStatus(id int, status, errMsg string) error {
|
||||
_, err := DB.Exec(`UPDATE jobs SET status = ?, error_message = ? WHERE id = ?`, status, nullStr(errMsg), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func BumpJobPriority(id int) error {
|
||||
_, err := DB.Exec(`UPDATE jobs SET priority = priority + 1 WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteJob(id int) error {
|
||||
_, err := DB.Exec(`DELETE FROM jobs WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func MarkProcessingAsFailed() error {
|
||||
_, err := DB.Exec(`UPDATE jobs SET status = 'failed', error_message = 'Interrupted by server restart' WHERE status = 'processing'`)
|
||||
return err
|
||||
}
|
||||
|
||||
func IsFileAlreadyProcessedOrQueued(filePath string) (bool, error) {
|
||||
var count int
|
||||
err := DB.QueryRow(`SELECT COUNT(*) FROM jobs WHERE file_path = ?`, filePath).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if count > 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
err = DB.QueryRow(`SELECT COUNT(*) FROM job_reports WHERE file_path = ?`, filePath).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// Job Reports
|
||||
|
||||
func AddJobReport(j Job, status string, encodedSize int64, sizeSaved int64, processingTime float64) error {
|
||||
_, err := DB.Exec(`INSERT INTO job_reports (file_path, media_type, status, original_size, encoded_size, size_saved, processing_time, target_resolution, ffmpeg_flags, error_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
j.FilePath, j.MediaType, status, j.OriginalSize, encodedSize, sizeSaved, processingTime, nullStr(j.TargetResolution), nullStr(j.FFmpegFlags), nullStr(j.ErrorMessage))
|
||||
return err
|
||||
}
|
||||
|
||||
func GetJobReports(limit, offset int) ([]JobReport, int, error) {
|
||||
var total int
|
||||
if err := DB.QueryRow(`SELECT COUNT(*) FROM job_reports`).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
rows, err := DB.Query(`SELECT id, file_path, media_type, status, original_size, encoded_size, size_saved, processing_time, target_resolution, ffmpeg_flags, error_message, created_at FROM job_reports ORDER BY created_at DESC LIMIT ? OFFSET ?`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var reports []JobReport
|
||||
for rows.Next() {
|
||||
var r JobReport
|
||||
var targetRes, ffmpegFlags, errMsg sql.NullString
|
||||
if err := rows.Scan(&r.ID, &r.FilePath, &r.MediaType, &r.Status, &r.OriginalSize, &r.EncodedSize, &r.SizeSaved, &r.ProcessingTime, &targetRes, &ffmpegFlags, &errMsg, &r.CreatedAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if targetRes.Valid { r.TargetResolution = targetRes.String }
|
||||
if ffmpegFlags.Valid { r.FFmpegFlags = ffmpegFlags.String }
|
||||
if errMsg.Valid { r.ErrorMessage = errMsg.String }
|
||||
reports = append(reports, r)
|
||||
}
|
||||
return reports, total, nil
|
||||
}
|
||||
|
||||
type DashboardStats struct {
|
||||
TotalSavedSpace int64
|
||||
FilesEncoded int
|
||||
QueueLength int
|
||||
}
|
||||
|
||||
func GetDashboardStats() (DashboardStats, error) {
|
||||
var stats DashboardStats
|
||||
|
||||
// Total saved space and files encoded
|
||||
err := DB.QueryRow(`
|
||||
SELECT
|
||||
COALESCE(SUM(size_saved), 0) as saved,
|
||||
COUNT(*) as count
|
||||
FROM job_reports
|
||||
WHERE status = 'success'
|
||||
`).Scan(&stats.TotalSavedSpace, &stats.FilesEncoded)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return stats, err
|
||||
}
|
||||
|
||||
// Queue length
|
||||
err = DB.QueryRow(`SELECT COUNT(*) FROM jobs`).Scan(&stats.QueueLength)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return stats, err
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package encoder
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (m *FFmpegManager) BuildAudioCmd(inputPath, outputPath, ffmpegFlags string) (*exec.Cmd, error) {
|
||||
args := []string{"-i", inputPath, "-c:a", "libmp3lame", "-b:a", "320k"}
|
||||
|
||||
if ffmpegFlags != "" {
|
||||
userArgs := ShlexSplit(ffmpegFlags)
|
||||
args = append(args, userArgs...)
|
||||
}
|
||||
|
||||
args = append(args, outputPath, "-y")
|
||||
|
||||
return exec.Command(m.BinaryPath, args...), nil
|
||||
}
|
||||
|
||||
func (m *FFmpegManager) ShouldSkipAudio(inputPath string) bool {
|
||||
// Probe the codec
|
||||
cmd := exec.Command("ffprobe", "-v", "quiet", "-select_streams", "a:0",
|
||||
"-show_entries", "stream=codec_name", "-of", "csv=p=0", inputPath)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
codec := strings.TrimSpace(string(out))
|
||||
|
||||
// If it's already mp3, check bitrate
|
||||
if codec == "mp3" {
|
||||
cmdBr := exec.Command("ffprobe", "-v", "quiet", "-select_streams", "a:0",
|
||||
"-show_entries", "stream=bit_rate", "-of", "csv=p=0", inputPath)
|
||||
outBr, err := cmdBr.Output()
|
||||
if err == nil {
|
||||
br, _ := strconv.Atoi(strings.TrimSpace(string(outBr)))
|
||||
if br >= 300000 {
|
||||
return true // It's ~320k mp3
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package encoder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FFmpegManager handles execution and probing
|
||||
type FFmpegManager struct {
|
||||
BinaryPath string
|
||||
}
|
||||
|
||||
func NewManager(binaryPath string) *FFmpegManager {
|
||||
if binaryPath == "" {
|
||||
binaryPath = "ffmpeg"
|
||||
}
|
||||
return &FFmpegManager{BinaryPath: binaryPath}
|
||||
}
|
||||
|
||||
func (m *FFmpegManager) ProbeCodec(filePath, streamType string) (string, error) {
|
||||
cmd := exec.Command("ffprobe", "-v", "quiet", "-select_streams", streamType+":0",
|
||||
"-show_entries", "stream=codec_name", "-of", "csv=p=0", filePath)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ffprobe error: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
func (m *FFmpegManager) ProbeResolution(filePath string) (int, int, error) {
|
||||
cmd := exec.Command("ffprobe", "-v", "quiet", "-select_streams", "v:0",
|
||||
"-show_entries", "stream=width,height", "-of", "csv=p=0", filePath)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("ffprobe error: %w", err)
|
||||
}
|
||||
parts := strings.Split(strings.TrimSpace(string(out)), ",")
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, fmt.Errorf("invalid resolution format")
|
||||
}
|
||||
w, err1 := strconv.Atoi(parts[0])
|
||||
h, err2 := strconv.Atoi(parts[1])
|
||||
if err1 != nil || err2 != nil {
|
||||
return 0, 0, fmt.Errorf("invalid resolution numbers")
|
||||
}
|
||||
return w, h, nil
|
||||
}
|
||||
|
||||
func (m *FFmpegManager) ProbeDuration(filePath string) (float64, error) {
|
||||
cmd := exec.Command("ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", filePath)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ffprobe error: %w", err)
|
||||
}
|
||||
return strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
|
||||
}
|
||||
|
||||
func (m *FFmpegManager) ValidateFile(filePath string) error {
|
||||
cmd := exec.Command("ffprobe", "-v", "quiet", "-show_format", "-show_streams", filePath)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("file validation failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseProgress is a utility to parse time=XX:XX:XX.XX from stderr line to calculate percentage if duration is known.
|
||||
// Will be used by the queue worker reading from a pipe.
|
||||
func ParseProgress(line string, durationSec float64) float64 {
|
||||
// Example line: frame= 123 fps= 30 q=22.0 size= 2048kB time=00:00:10.50 bitrate=1500.0kbits/s speed=1.5x
|
||||
idx := strings.Index(line, "time=")
|
||||
if idx == -1 {
|
||||
return -1
|
||||
}
|
||||
timeStr := line[idx+5:]
|
||||
spaceIdx := strings.Index(timeStr, " ")
|
||||
if spaceIdx != -1 {
|
||||
timeStr = timeStr[:spaceIdx]
|
||||
}
|
||||
|
||||
// timeStr is HH:MM:SS.ms
|
||||
parts := strings.Split(timeStr, ":")
|
||||
if len(parts) != 3 {
|
||||
return -1
|
||||
}
|
||||
|
||||
h, _ := strconv.ParseFloat(parts[0], 64)
|
||||
m, _ := strconv.ParseFloat(parts[1], 64)
|
||||
s, _ := strconv.ParseFloat(parts[2], 64)
|
||||
|
||||
currentSec := h*3600 + m*60 + s
|
||||
if durationSec > 0 {
|
||||
return (currentSec / durationSec) * 100.0
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// ShlexSplit splits a command string like a shell would.
|
||||
// A simple implementation since go doesn't have shlex.split built-in.
|
||||
func ShlexSplit(s string) []string {
|
||||
var args []string
|
||||
var buf bytes.Buffer
|
||||
inQuotes := false
|
||||
var quoteChar rune
|
||||
|
||||
for _, r := range s {
|
||||
if inQuotes {
|
||||
if r == quoteChar {
|
||||
inQuotes = false
|
||||
} else {
|
||||
buf.WriteRune(r)
|
||||
}
|
||||
} else {
|
||||
if r == '\'' || r == '"' {
|
||||
inQuotes = true
|
||||
quoteChar = r
|
||||
} else if r == ' ' {
|
||||
if buf.Len() > 0 {
|
||||
args = append(args, buf.String())
|
||||
buf.Reset()
|
||||
}
|
||||
} else {
|
||||
buf.WriteRune(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
if buf.Len() > 0 {
|
||||
args = append(args, buf.String())
|
||||
}
|
||||
return args
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package encoder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (m *FFmpegManager) BuildVideoCmd(inputPath, outputPath, targetResolution, ffmpegFlags string, originalWidth, originalHeight int) (*exec.Cmd, error) {
|
||||
args := []string{"-i", inputPath}
|
||||
|
||||
if targetResolution != "" {
|
||||
targetHeightStr := strings.TrimRight(strings.ToLower(targetResolution), "p")
|
||||
targetHeight, err := strconv.Atoi(targetHeightStr)
|
||||
if err == nil && targetHeight > 0 && originalHeight > targetHeight {
|
||||
scaleWidth := int((float64(originalWidth) / float64(originalHeight)) * float64(targetHeight))
|
||||
if scaleWidth%2 != 0 {
|
||||
scaleWidth--
|
||||
}
|
||||
args = append(args, "-vf", fmt.Sprintf("scale=%d:%d", scaleWidth, targetHeight))
|
||||
}
|
||||
}
|
||||
|
||||
args = append(args, "-c:v", "libx265", "-c:a", "copy", "-preset", "medium", "-crf", "22", "-f", "matroska")
|
||||
|
||||
if ffmpegFlags != "" {
|
||||
userArgs := ShlexSplit(ffmpegFlags)
|
||||
args = append(args, userArgs...)
|
||||
}
|
||||
|
||||
args = append(args, outputPath, "-y")
|
||||
|
||||
return exec.Command(m.BinaryPath, args...), nil
|
||||
}
|
||||
|
||||
func (m *FFmpegManager) ShouldSkipVideo(inputPath, targetResolution string) bool {
|
||||
// Probe the codec
|
||||
cmd := exec.Command("ffprobe", "-v", "quiet", "-select_streams", "v:0",
|
||||
"-show_entries", "stream=codec_name", "-of", "csv=p=0", inputPath)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
codec := strings.TrimSpace(string(out))
|
||||
|
||||
// Probe the height
|
||||
cmdHeight := exec.Command("ffprobe", "-v", "quiet", "-select_streams", "v:0",
|
||||
"-show_entries", "stream=height", "-of", "csv=p=0", inputPath)
|
||||
outHeight, err := cmdHeight.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// If it's not H.264 or H.265, do not skip (encode it)
|
||||
if codec != "h264" && codec != "hevc" {
|
||||
return false
|
||||
}
|
||||
|
||||
// If it's H.265 and no target resolution is set, it's already in the target codec
|
||||
if codec == "hevc" && targetResolution == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// If it's H.265 and target resolution is set, only skip if current height is <= target height
|
||||
if codec == "hevc" && targetResolution != "" {
|
||||
currentHeight, _ := strconv.Atoi(strings.TrimSpace(string(outHeight)))
|
||||
targetHeight, _ := strconv.Atoi(strings.TrimRight(strings.ToLower(targetResolution), "p"))
|
||||
if currentHeight > 0 && targetHeight > 0 && currentHeight <= targetHeight {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"container/ring"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LogEntry struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
var (
|
||||
ringBuffer *ring.Ring
|
||||
bufferMu sync.Mutex
|
||||
Broadcast func(string, interface{})
|
||||
)
|
||||
|
||||
type customWriter struct {
|
||||
stdout io.Writer
|
||||
}
|
||||
|
||||
func (cw *customWriter) Write(p []byte) (n int, err error) {
|
||||
now := time.Now()
|
||||
msg := strings.TrimSpace(string(p))
|
||||
|
||||
// Write to stdout with simple timestamp
|
||||
outStr := now.Format("2006/01/02 15:04:05 ") + msg + "\n"
|
||||
cw.stdout.Write([]byte(outStr))
|
||||
|
||||
if msg == "" {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
entry := LogEntry{
|
||||
Timestamp: now,
|
||||
Message: msg,
|
||||
}
|
||||
|
||||
bufferMu.Lock()
|
||||
if ringBuffer == nil {
|
||||
ringBuffer = ring.New(100)
|
||||
}
|
||||
ringBuffer.Value = entry
|
||||
ringBuffer = ringBuffer.Next()
|
||||
bufferMu.Unlock()
|
||||
|
||||
if Broadcast != nil {
|
||||
Broadcast("log", entry)
|
||||
}
|
||||
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func Init(broadcast func(string, interface{})) {
|
||||
Broadcast = broadcast
|
||||
ringBuffer = ring.New(100)
|
||||
log.SetOutput(&customWriter{stdout: os.Stdout})
|
||||
// Remove timestamp from log format since we add it in LogEntry or we can let log package add it
|
||||
// Wait, standard log adds timestamp. If standard log adds timestamp, our `msg` will contain the timestamp.
|
||||
// But `LogEntry` has a `Timestamp` field for the JSON.
|
||||
// It's cleaner to remove the standard log flags so `msg` is just the message, and let stdout print the plain msg?
|
||||
// But stdout wouldn't have timestamps then unless we format it ourselves.
|
||||
// Let's just remove log flags and format it ourselves for stdout!
|
||||
log.SetFlags(0)
|
||||
}
|
||||
|
||||
// Since we removed log flags, we need to handle stdout formatting in Write if we want timestamps in stdout.
|
||||
// Actually, let's keep the timestamp in stdout, we can modify customWriter.Write to prepend the timestamp to stdout.
|
||||
// We'll update the writer in a moment if needed.
|
||||
|
||||
func GetRecentLogs() []LogEntry {
|
||||
bufferMu.Lock()
|
||||
defer bufferMu.Unlock()
|
||||
|
||||
var logs []LogEntry
|
||||
if ringBuffer != nil {
|
||||
ringBuffer.Do(func(p interface{}) {
|
||||
if p != nil {
|
||||
logs = append(logs, p.(LogEntry))
|
||||
}
|
||||
})
|
||||
}
|
||||
return logs
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"goencode/internal/db"
|
||||
"goencode/internal/encoder"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
FFmpegPath string
|
||||
TempDir string
|
||||
TriggerChan chan struct{}
|
||||
StopChan chan struct{}
|
||||
Broadcast func(string, interface{})
|
||||
encoder *encoder.FFmpegManager
|
||||
isProcessing bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewManager(ffmpegPath, tempDir string, broadcast func(string, interface{})) *Manager {
|
||||
return &Manager{
|
||||
FFmpegPath: ffmpegPath,
|
||||
TempDir: tempDir,
|
||||
TriggerChan: make(chan struct{}, 1),
|
||||
StopChan: make(chan struct{}),
|
||||
Broadcast: broadcast,
|
||||
encoder: encoder.NewManager(ffmpegPath),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Start() {
|
||||
// Mark any interrupted processing jobs as failed
|
||||
if err := db.MarkProcessingAsFailed(); err != nil {
|
||||
log.Printf("Failed to mark interrupted jobs: %v", err)
|
||||
}
|
||||
|
||||
go m.workerLoop()
|
||||
m.Trigger() // Initial trigger
|
||||
}
|
||||
|
||||
func (m *Manager) Stop() {
|
||||
close(m.StopChan)
|
||||
}
|
||||
|
||||
func (m *Manager) Trigger() {
|
||||
select {
|
||||
case m.TriggerChan <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) NotifySSE(event string, data interface{}) {
|
||||
if m.Broadcast != nil {
|
||||
m.Broadcast(event, data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"goencode/internal/db"
|
||||
"goencode/internal/encoder"
|
||||
"io"
|
||||
)
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Sync()
|
||||
}
|
||||
|
||||
func (m *Manager) workerLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-m.StopChan:
|
||||
return
|
||||
case <-m.TriggerChan:
|
||||
m.processNextJob()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) processNextJob() {
|
||||
m.mu.Lock()
|
||||
if m.isProcessing {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
m.isProcessing = true
|
||||
m.mu.Unlock()
|
||||
|
||||
var hasJobs bool
|
||||
defer func() {
|
||||
m.mu.Lock()
|
||||
m.isProcessing = false
|
||||
m.mu.Unlock()
|
||||
// Only trigger immediately if we know there might be more jobs
|
||||
if hasJobs {
|
||||
m.Trigger()
|
||||
}
|
||||
}()
|
||||
|
||||
jobs, err := db.GetPendingJobs()
|
||||
if err != nil {
|
||||
log.Printf("Failed to get pending jobs: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(jobs) == 0 {
|
||||
return
|
||||
}
|
||||
hasJobs = true
|
||||
|
||||
job := jobs[0]
|
||||
log.Printf("Starting job %d for %s", job.ID, job.FilePath)
|
||||
|
||||
m.NotifySSE("job_started", job)
|
||||
|
||||
err = db.UpdateJobStatus(job.ID, "processing", "")
|
||||
if err != nil {
|
||||
log.Printf("Failed to update job status: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
err = m.runEncoder(job)
|
||||
if err != nil {
|
||||
log.Printf("Job %d failed: %v", job.ID, err)
|
||||
db.UpdateJobStatus(job.ID, "failed", err.Error())
|
||||
db.AddJobReport(job, "failed", 0, 0, 0)
|
||||
m.NotifySSE("job_failed", map[string]interface{}{"id": job.ID, "error": err.Error()})
|
||||
} else {
|
||||
log.Printf("Job %d succeeded", job.ID)
|
||||
m.NotifySSE("job_completed", map[string]interface{}{"id": job.ID})
|
||||
}
|
||||
db.DeleteJob(job.ID)
|
||||
}
|
||||
|
||||
func (m *Manager) runEncoder(job db.Job) error {
|
||||
startTime := time.Now()
|
||||
|
||||
if _, err := os.Stat(job.FilePath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("source file missing")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(m.TempDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create temp dir: %w", err)
|
||||
}
|
||||
|
||||
originalSize := job.OriginalSize
|
||||
if originalSize == 0 {
|
||||
info, err := os.Stat(job.FilePath)
|
||||
if err == nil {
|
||||
originalSize = info.Size()
|
||||
}
|
||||
}
|
||||
|
||||
ext := filepath.Ext(job.FilePath)
|
||||
var outExt string
|
||||
if job.MediaType == "video" {
|
||||
outExt = ".mkv"
|
||||
} else {
|
||||
outExt = ".mp3"
|
||||
}
|
||||
|
||||
baseName := strings.TrimSuffix(filepath.Base(job.FilePath), ext)
|
||||
tempOutPath := filepath.Join(m.TempDir, fmt.Sprintf("temp_%d_%s%s", job.ID, baseName, outExt))
|
||||
finalOutPath := filepath.Join(filepath.Dir(job.FilePath), baseName+outExt)
|
||||
|
||||
duration, _ := m.encoder.ProbeDuration(job.FilePath)
|
||||
|
||||
tempInPath := filepath.Join(m.TempDir, fmt.Sprintf("temp_in_%d_%s", job.ID, filepath.Base(job.FilePath)))
|
||||
|
||||
var cmdErr error
|
||||
var execCmd *exec.Cmd
|
||||
|
||||
if job.MediaType == "video" {
|
||||
if m.encoder.ShouldSkipVideo(job.FilePath, job.TargetResolution) {
|
||||
log.Printf("Skipping video %d - already target codec/resolution", job.ID)
|
||||
return db.AddJobReport(job, "skipped", originalSize, 0, 0)
|
||||
}
|
||||
|
||||
w, h, err := m.encoder.ProbeResolution(job.FilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to probe resolution: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Copying %s to %s before encoding...", job.FilePath, tempInPath)
|
||||
if err := copyFile(job.FilePath, tempInPath); err != nil {
|
||||
return fmt.Errorf("failed to copy source to temp: %w", err)
|
||||
}
|
||||
defer os.Remove(tempInPath)
|
||||
|
||||
execCmd, err = m.encoder.BuildVideoCmd(tempInPath, tempOutPath, job.TargetResolution, job.FFmpegFlags, w, h)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if m.encoder.ShouldSkipAudio(job.FilePath) {
|
||||
log.Printf("Skipping audio %d - already target codec/bitrate", job.ID)
|
||||
return db.AddJobReport(job, "skipped", originalSize, 0, 0)
|
||||
}
|
||||
|
||||
log.Printf("Copying %s to %s before encoding...", job.FilePath, tempInPath)
|
||||
if err := copyFile(job.FilePath, tempInPath); err != nil {
|
||||
return fmt.Errorf("failed to copy source to temp: %w", err)
|
||||
}
|
||||
defer os.Remove(tempInPath)
|
||||
|
||||
execCmd, cmdErr = m.encoder.BuildAudioCmd(tempInPath, tempOutPath, job.FFmpegFlags)
|
||||
if cmdErr != nil {
|
||||
return cmdErr
|
||||
}
|
||||
}
|
||||
|
||||
stderr, err := execCmd.StderrPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := execCmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start ffmpeg: %w", err)
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(stderr)
|
||||
scanner.Split(bufio.ScanLines)
|
||||
// Some ffmpeg progress output uses carriage returns instead of newlines
|
||||
// We handle standard lines, to get a more robust carriage return split, we could implement a custom split function.
|
||||
// For simplicity, ScanLines usually catches 'time=' lines well enough if they have \n.
|
||||
// Let's use a custom split to handle '\r' as well.
|
||||
scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||
if atEOF && len(data) == 0 {
|
||||
return 0, nil, nil
|
||||
}
|
||||
if i := bytes.IndexByte(data, '\r'); i >= 0 {
|
||||
return i + 1, data[0:i], nil
|
||||
}
|
||||
if i := bytes.IndexByte(data, '\n'); i >= 0 {
|
||||
return i + 1, data[0:i], nil
|
||||
}
|
||||
if atEOF {
|
||||
return len(data), data, nil
|
||||
}
|
||||
return 0, nil, nil
|
||||
})
|
||||
|
||||
var lastErrLine string
|
||||
go func() {
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
lastErrLine = line
|
||||
if duration > 0 {
|
||||
prog := encoder.ParseProgress(line, duration)
|
||||
if prog >= 0 {
|
||||
m.NotifySSE("progress", map[string]interface{}{
|
||||
"id": job.ID,
|
||||
"progress": fmt.Sprintf("%.1f", prog),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if err := execCmd.Wait(); err != nil {
|
||||
return fmt.Errorf("ffmpeg error: %v, last output: %s", err, lastErrLine)
|
||||
}
|
||||
|
||||
// Validate
|
||||
if err := m.encoder.ValidateFile(tempOutPath); err != nil {
|
||||
os.Remove(tempOutPath)
|
||||
return fmt.Errorf("validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Calculate sizes
|
||||
outInfo, err := os.Stat(tempOutPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stat output: %w", err)
|
||||
}
|
||||
encodedSize := outInfo.Size()
|
||||
sizeSaved := originalSize - encodedSize
|
||||
|
||||
// Move file
|
||||
log.Printf("Copying encoded file back to %s...", finalOutPath)
|
||||
if err := os.Rename(tempOutPath, finalOutPath); err != nil {
|
||||
// Fallback to copy if cross-device link error
|
||||
if err := copyFile(tempOutPath, finalOutPath); err != nil {
|
||||
return fmt.Errorf("failed to move output: %w", err)
|
||||
}
|
||||
os.Remove(tempOutPath)
|
||||
}
|
||||
|
||||
// Delete original if different extension
|
||||
if finalOutPath != job.FilePath {
|
||||
os.Remove(job.FilePath)
|
||||
}
|
||||
|
||||
processTime := time.Since(startTime).Seconds()
|
||||
return db.AddJobReport(job, "success", encodedSize, sizeSaved, processTime)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"goencode/internal/db"
|
||||
"goencode/internal/queue"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
watcher *fsnotify.Watcher
|
||||
queueManager *queue.Manager
|
||||
timers map[string]*time.Timer
|
||||
timersMu sync.Mutex
|
||||
processChan chan string
|
||||
stopChan chan struct{}
|
||||
}
|
||||
|
||||
func NewManager(qm *queue.Manager) (*Manager, error) {
|
||||
w, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
watcher: w,
|
||||
queueManager: qm,
|
||||
timers: make(map[string]*time.Timer),
|
||||
processChan: make(chan string, 10000),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
go m.processWorker()
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Start() {
|
||||
m.Reload()
|
||||
go m.watchLoop()
|
||||
}
|
||||
|
||||
func (m *Manager) Stop() {
|
||||
close(m.stopChan)
|
||||
m.watcher.Close()
|
||||
}
|
||||
|
||||
func (m *Manager) Reload() {
|
||||
// Remove all existing watches
|
||||
for _, path := range m.watcher.WatchList() {
|
||||
m.watcher.Remove(path)
|
||||
}
|
||||
|
||||
folders, err := db.GetWatchFolders()
|
||||
if err != nil {
|
||||
log.Printf("Watcher failed to get folders: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, f := range folders {
|
||||
if !f.Enabled {
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(f.FolderPath, 0755); err != nil {
|
||||
log.Printf("Failed to create watch folder %s: %v", f.FolderPath, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("Watching and scanning %s", f.FolderPath)
|
||||
|
||||
// Walk the directory to add all subdirectories to watcher and scan existing files
|
||||
filepath.Walk(f.FolderPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
if err := m.watcher.Add(path); err != nil {
|
||||
log.Printf("Failed to watch %s: %v", path, err)
|
||||
}
|
||||
} else {
|
||||
// Process existing file asynchronously
|
||||
go m.handleEvent(path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) watchLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-m.stopChan:
|
||||
return
|
||||
case event, ok := <-m.watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) {
|
||||
// Check if the created event is a directory
|
||||
if event.Has(fsnotify.Create) {
|
||||
info, err := os.Stat(event.Name)
|
||||
if err == nil && info.IsDir() {
|
||||
filepath.Walk(event.Name, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
m.watcher.Add(path)
|
||||
} else {
|
||||
go m.handleEvent(path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
m.handleEvent(event.Name)
|
||||
}
|
||||
case err, ok := <-m.watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Printf("Watcher error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) processWorker() {
|
||||
for {
|
||||
select {
|
||||
case <-m.stopChan:
|
||||
return
|
||||
case path := <-m.processChan:
|
||||
m.processFile(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) handleEvent(filePath string) {
|
||||
m.timersMu.Lock()
|
||||
defer m.timersMu.Unlock()
|
||||
|
||||
if t, exists := m.timers[filePath]; exists {
|
||||
t.Stop()
|
||||
}
|
||||
|
||||
m.timers[filePath] = time.AfterFunc(5*time.Second, func() {
|
||||
m.timersMu.Lock()
|
||||
delete(m.timers, filePath)
|
||||
m.timersMu.Unlock()
|
||||
|
||||
select {
|
||||
case m.processChan <- filePath:
|
||||
default:
|
||||
log.Printf("Process queue full, dropping %s", filePath)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) processFile(filePath string) {
|
||||
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil || info.IsDir() {
|
||||
return // File removed or is a directory
|
||||
}
|
||||
|
||||
// Make sure it's not a temp file
|
||||
if filepath.Ext(filePath) == ".tmp" {
|
||||
return
|
||||
}
|
||||
|
||||
alreadyInQueue, err := db.IsFileAlreadyProcessedOrQueued(filePath)
|
||||
if err != nil {
|
||||
log.Printf("Error checking DB for %s: %v", filePath, err)
|
||||
return
|
||||
}
|
||||
if alreadyInQueue {
|
||||
return
|
||||
}
|
||||
|
||||
// Find which watch folder it belongs to
|
||||
folders, err := db.GetWatchFolders()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var match db.WatchFolder
|
||||
found := false
|
||||
for _, f := range folders {
|
||||
if !f.Enabled {
|
||||
continue
|
||||
}
|
||||
// Check if filePath is inside f.FolderPath
|
||||
cleanPath := filepath.Clean(filePath)
|
||||
folderPath := filepath.Clean(f.FolderPath)
|
||||
if strings.HasPrefix(cleanPath, folderPath+string(os.PathSeparator)) || cleanPath == folderPath || filepath.Dir(cleanPath) == folderPath {
|
||||
match = f
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
|
||||
err = db.AddJob(filePath, match.MediaType, 0, match.TargetResolution, match.CustomFFmpegFlags, info.Size())
|
||||
if err != nil {
|
||||
log.Printf("Failed to add job for %s: %v", filePath, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Added job for %s", filePath)
|
||||
m.queueManager.NotifySSE("job_added", nil)
|
||||
m.queueManager.Trigger()
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"goencode/internal/db"
|
||||
)
|
||||
|
||||
func (s *Server) handleGetQueue(w http.ResponseWriter, r *http.Request) {
|
||||
jobs, err := db.GetPendingJobs()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(jobs)
|
||||
}
|
||||
|
||||
func (s *Server) handleBumpJob(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/api/jobs/bump/"))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := db.BumpJobPriority(id); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.qm.NotifySSE("queue_updated", nil)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) handleCancelJob(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/api/jobs/cancel/"))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := db.DeleteJob(id); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.qm.NotifySSE("queue_updated", nil)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetWatchFolders(w http.ResponseWriter, r *http.Request) {
|
||||
folders, err := db.GetWatchFolders()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(folders)
|
||||
}
|
||||
|
||||
func (s *Server) handleAddWatchFolder(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var f db.WatchFolder
|
||||
if err := json.NewDecoder(r.Body).Decode(&f); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
f.Enabled = true
|
||||
if err := db.AddWatchFolder(f); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.wm.Reload()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteWatchFolder(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/api/folders/delete/"))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := db.DeleteWatchFolder(id); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.wm.Reload()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"goencode/internal/config"
|
||||
"goencode/internal/db"
|
||||
"goencode/internal/logger"
|
||||
"goencode/internal/queue"
|
||||
"goencode/internal/watcher"
|
||||
rootweb "goencode/web"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
qm *queue.Manager
|
||||
wm *watcher.Manager
|
||||
sse *SSEServer
|
||||
mux *http.ServeMux
|
||||
sessionToken string
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.Config, qm *queue.Manager, wm *watcher.Manager, sse *SSEServer) *Server {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
token := hex.EncodeToString(b)
|
||||
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
qm: qm,
|
||||
wm: wm,
|
||||
sse: sse,
|
||||
mux: http.NewServeMux(),
|
||||
sessionToken: token,
|
||||
}
|
||||
s.routes()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
addr := fmt.Sprintf("%s:%d", s.cfg.Server.ListenAddr, s.cfg.Server.Port)
|
||||
|
||||
var handler http.Handler = s.mux
|
||||
if s.cfg.Auth.Username != "" {
|
||||
handler = s.authMiddleware(s.mux)
|
||||
}
|
||||
|
||||
return http.ListenAndServe(addr, handler)
|
||||
}
|
||||
|
||||
func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
importStrings := true // to ensure "strings" is imported if not already, wait, I need to check if strings is imported.
|
||||
_ = importStrings
|
||||
|
||||
// We'll use a simple slice of prefixes to bypass auth, but since there's only one,
|
||||
// let's just use string slicing or import "strings"
|
||||
|
||||
// To be safe without adding imports manually if I don't know the exact list:
|
||||
if s.cfg.Auth.Username == "" || r.URL.Path == "/login" || (len(r.URL.Path) >= 8 && r.URL.Path[:8] == "/static/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
cookie, err := r.Cookie("goencode_session")
|
||||
if err != nil || cookie.Value != s.sessionToken {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" {
|
||||
tmpl, err := template.ParseFS(rootweb.FS, "templates/login.html")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
tmpl.Execute(w, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == "POST" {
|
||||
user := r.FormValue("username")
|
||||
pass := r.FormValue("password")
|
||||
|
||||
if user == s.cfg.Auth.Username && pass == s.cfg.Auth.Password {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "goencode_session",
|
||||
Value: s.sessionToken,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
})
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl, _ := template.ParseFS(rootweb.FS, "templates/login.html")
|
||||
tmpl.Execute(w, struct{ Error string }{"Invalid credentials"})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "goencode_session",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
})
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) routes() {
|
||||
s.mux.HandleFunc("/login", s.handleLogin)
|
||||
s.mux.HandleFunc("/logout", s.handleLogout)
|
||||
|
||||
s.mux.HandleFunc("/api/sse", s.sse.HandleSSE)
|
||||
|
||||
s.mux.HandleFunc("/api/queue", s.handleGetQueue)
|
||||
s.mux.HandleFunc("/api/jobs/bump/", s.handleBumpJob)
|
||||
s.mux.HandleFunc("/api/jobs/cancel/", s.handleCancelJob)
|
||||
|
||||
s.mux.HandleFunc("/api/folders", s.handleGetWatchFolders)
|
||||
s.mux.HandleFunc("/api/folders/add", s.handleAddWatchFolder)
|
||||
s.mux.HandleFunc("/api/folders/delete/", s.handleDeleteWatchFolder)
|
||||
s.mux.HandleFunc("/api/logs", s.handleGetLogs)
|
||||
|
||||
// Static files
|
||||
s.mux.Handle("/static/", http.FileServer(http.FS(rootweb.FS)))
|
||||
|
||||
// Pages
|
||||
s.mux.HandleFunc("/", s.handleDashboard)
|
||||
s.mux.HandleFunc("/folders", s.handlePage("folders.html"))
|
||||
s.mux.HandleFunc("/history", s.handleHistory)
|
||||
s.mux.HandleFunc("/logs", s.handlePage("logs.html"))
|
||||
s.mux.HandleFunc("/settings", s.handlePage("settings.html"))
|
||||
}
|
||||
|
||||
func (s *Server) handleGetLogs(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
logs := logger.GetRecentLogs()
|
||||
json.NewEncoder(w).Encode(logs)
|
||||
}
|
||||
|
||||
func formatBytes(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit && bytes > -unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
n := bytes
|
||||
if n < 0 {
|
||||
n = -n
|
||||
}
|
||||
for n >= unit*unit && exp < len("KMGTPE")-1 {
|
||||
div *= unit
|
||||
exp++
|
||||
n /= unit
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
stats, _ := db.GetDashboardStats()
|
||||
|
||||
// Create formatted stats
|
||||
data := struct {
|
||||
AuthEnabled bool
|
||||
FilesEncoded int
|
||||
QueueLength int
|
||||
SavedSpace string
|
||||
}{
|
||||
AuthEnabled: s.cfg.Auth.Username != "",
|
||||
FilesEncoded: stats.FilesEncoded,
|
||||
QueueLength: stats.QueueLength,
|
||||
SavedSpace: formatBytes(stats.TotalSavedSpace),
|
||||
}
|
||||
|
||||
tmpl, err := template.New("layout").Funcs(template.FuncMap{
|
||||
"formatBytes": formatBytes,
|
||||
}).ParseFS(rootweb.FS, "templates/layout.html", "templates/dashboard.html")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
tmpl.ExecuteTemplate(w, "layout", data)
|
||||
}
|
||||
|
||||
func (s *Server) handlePage(tmplName string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tmpl, err := template.ParseFS(rootweb.FS, "templates/layout.html", filepath.Join("templates", tmplName))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := struct{ AuthEnabled bool }{AuthEnabled: s.cfg.Auth.Username != ""}
|
||||
if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
log.Printf("Template execution error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleHistory(w http.ResponseWriter, r *http.Request) {
|
||||
reports, total, err := db.GetJobReports(50, 0) // Basic pagination for now
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := struct {
|
||||
AuthEnabled bool
|
||||
Reports []db.JobReport
|
||||
Total int
|
||||
}{
|
||||
AuthEnabled: s.cfg.Auth.Username != "",
|
||||
Reports: reports,
|
||||
Total: total,
|
||||
}
|
||||
|
||||
tmpl, err := template.New("layout").Funcs(template.FuncMap{
|
||||
"formatBytes": formatBytes,
|
||||
}).ParseFS(rootweb.FS, "templates/layout.html", "templates/history.html")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
log.Printf("Template execution error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SSEMessage struct {
|
||||
Event string
|
||||
Data interface{}
|
||||
}
|
||||
|
||||
type SSEServer struct {
|
||||
clients map[chan SSEMessage]bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewSSEServer() *SSEServer {
|
||||
return &SSEServer{
|
||||
clients: make(map[chan SSEMessage]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SSEServer) HandleSSE(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
clientChan := make(chan SSEMessage)
|
||||
s.mu.Lock()
|
||||
s.clients[clientChan] = true
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.clients, clientChan)
|
||||
s.mu.Unlock()
|
||||
close(clientChan)
|
||||
}()
|
||||
|
||||
notify := r.Context().Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-notify:
|
||||
return
|
||||
case msg := <-clientChan:
|
||||
dataBytes, err := json.Marshal(msg.Data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", msg.Event, string(dataBytes))
|
||||
w.(http.Flusher).Flush()
|
||||
case <-time.After(30 * time.Second):
|
||||
// Keepalive
|
||||
fmt.Fprintf(w, "event: ping\ndata: {}\n\n")
|
||||
w.(http.Flusher).Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SSEServer) Broadcast(event string, data interface{}) {
|
||||
msg := SSEMessage{Event: event, Data: data}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for clientChan := range s.clients {
|
||||
select {
|
||||
case clientChan <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user