add: add encoder, mover, watcher, and logger packages
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
package encoder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"videnc-vibe/pkg/types"
|
||||
)
|
||||
|
||||
type Encoder struct {
|
||||
ffmpegPath string
|
||||
opusencPath string
|
||||
}
|
||||
|
||||
func New() *Encoder {
|
||||
return &Encoder{
|
||||
ffmpegPath: "ffmpeg",
|
||||
opusencPath: "opusenc",
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Encoder) CheckDeps() error {
|
||||
if _, err := exec.LookPath(e.ffmpegPath); err != nil {
|
||||
return fmt.Errorf("ffmpeg not found")
|
||||
}
|
||||
if _, err := exec.LookPath(e.opusencPath); err != nil {
|
||||
return fmt.Errorf("opusenc not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Encoder) GetMediaInfo(path string) (width, height int, err error) {
|
||||
cmd := exec.Command(e.ffmpegPath, "-i", path, "-hide_banner")
|
||||
output, _ := cmd.StderrPipe()
|
||||
cmd.Start()
|
||||
|
||||
buf := make([]byte, 8192)
|
||||
n, _ := output.Read(buf)
|
||||
output.Close()
|
||||
|
||||
var w, h int
|
||||
fmt.Sscanf(string(buf[:n]), "%dx%d", &w, &h)
|
||||
return w, h, nil
|
||||
}
|
||||
|
||||
func (e *Encoder) Transcode(input string, job *types.Job, metadata *types.Metadata) error {
|
||||
dir := filepath.Dir(input)
|
||||
audioWavs, err := e.extractAudio(input, dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("extracting audio: %w", err)
|
||||
}
|
||||
defer e.cleanupWavs(audioWavs)
|
||||
|
||||
opusFiles, err := e.encodeOpus(audioWavs, dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encoding opus: %w", err)
|
||||
}
|
||||
defer e.cleanupOpus(opusFiles)
|
||||
|
||||
if err := e.encodeVideo(input, opusFiles, job, metadata); err != nil {
|
||||
return fmt.Errorf("encoding video: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Encoder) extractAudio(input, dir string) ([]string, error) {
|
||||
cmd := exec.Command(e.ffmpegPath, "-i", input,
|
||||
"-vn", "-c:a", "pcm_s16le", "-ar", "48000",
|
||||
"-f", "wav", filepath.Join(dir, "audio_%d.wav"))
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("ffmpeg extract: %s %w", out, err)
|
||||
}
|
||||
|
||||
matches, _ := filepath.Glob(filepath.Join(dir, "audio_*.wav"))
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func (e *Encoder) encodeOpus(wavs []string, dir string) ([]string, error) {
|
||||
var opusFiles []string
|
||||
for _, wav := range wavs {
|
||||
out := strings.TrimSuffix(wav, ".wav") + ".opus"
|
||||
cmd := exec.Command(e.opusencPath, "--bitrate", "128k", wav, out)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("opusenc: %s %w", out, err)
|
||||
}
|
||||
opusFiles = append(opusFiles, out)
|
||||
}
|
||||
return opusFiles, nil
|
||||
}
|
||||
|
||||
func (e *Encoder) encodeVideo(input string, opusFiles []string, job *types.Job, metadata *types.Metadata) error {
|
||||
dir := filepath.Dir(input)
|
||||
outFile := filepath.Join(dir, "output.mkv")
|
||||
|
||||
svtParams := fmt.Sprintf("film-grain=10:film-grain-denoise=1:scd=1:qm-min=4:qm-max=15:preset=%d", job.Preset)
|
||||
|
||||
args := []string{
|
||||
"-y",
|
||||
"-i", input,
|
||||
"-c:v", "libsvtav1",
|
||||
"-crf", strconv.Itoa(job.CRF),
|
||||
"-svtav1-params", svtParams,
|
||||
"-pix_fmt", "yuv420p10le",
|
||||
"-vf", "zscale=filter=spline36:mode=16:9",
|
||||
"-c:a", "copy",
|
||||
"-c:s", "copy",
|
||||
}
|
||||
|
||||
for _, opus := range opusFiles {
|
||||
args = append(args, "-i", opus)
|
||||
}
|
||||
|
||||
args = append(args, "-map", "0:v")
|
||||
args = append(args, "-map", "0:s?")
|
||||
|
||||
for i := range opusFiles {
|
||||
args = append(args, "-map", fmt.Sprintf("%d:a", i+1))
|
||||
}
|
||||
|
||||
args = append(args, "-metadata", fmt.Sprintf("TITLE=%s", metadata.Title))
|
||||
args = append(args, "-metadata", fmt.Sprintf("DATE_RELEASED=%s", metadata.DateReleased))
|
||||
args = append(args, "-metadata", fmt.Sprintf("IMDBID=%s", metadata.IMDBID))
|
||||
args = append(args, "-metadata", fmt.Sprintf("ORIGINAL_MEDIA_TYPE=%s", metadata.OriginalMedia))
|
||||
|
||||
if metadata.IsSeries {
|
||||
args = append(args, "-metadata", fmt.Sprintf("COLLECTION=%s", metadata.Collection))
|
||||
args = append(args, "-metadata", fmt.Sprintf("SEASON=%s", metadata.Season))
|
||||
args = append(args, "-metadata", fmt.Sprintf("EPISODE=%s", metadata.Episode))
|
||||
if metadata.TVMazeID != "" {
|
||||
args = append(args, "-metadata", fmt.Sprintf("TVMAZE_ID=%s", metadata.TVMazeID))
|
||||
}
|
||||
}
|
||||
|
||||
args = append(args, outFile)
|
||||
|
||||
cmd := exec.Command(e.ffmpegPath, args...)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("ffmpeg encode: %s %w", out, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Encoder) cleanupWavs(files []string) {
|
||||
for _, f := range files {
|
||||
os.Remove(f)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Encoder) cleanupOpus(files []string) {
|
||||
for _, f := range files {
|
||||
os.Remove(f)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"videnc-vibe/pkg/types"
|
||||
)
|
||||
|
||||
type Logger struct {
|
||||
infoPath string
|
||||
errorPath string
|
||||
structPath string
|
||||
infoFile *os.File
|
||||
errorFile *os.File
|
||||
structFile *os.File
|
||||
}
|
||||
|
||||
func New(logDir string) (*Logger, error) {
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("creating log directory: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now().Format("2006-01-02")
|
||||
infoPath := filepath.Join(logDir, fmt.Sprintf("info_%s.log", now))
|
||||
errorPath := filepath.Join(logDir, fmt.Sprintf("error_%s.log", now))
|
||||
structPath := filepath.Join(logDir, "structured.json")
|
||||
|
||||
infoFile, err := os.OpenFile(infoPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening info log: %w", err)
|
||||
}
|
||||
|
||||
errorFile, err := os.OpenFile(errorPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening error log: %w", err)
|
||||
}
|
||||
|
||||
structFile, err := os.OpenFile(structPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening structured log: %w", err)
|
||||
}
|
||||
|
||||
return &Logger{
|
||||
infoPath: infoPath,
|
||||
errorPath: errorPath,
|
||||
structPath: structPath,
|
||||
infoFile: infoFile,
|
||||
errorFile: errorFile,
|
||||
structFile: structFile,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (l *Logger) Info(message string) {
|
||||
timestamp := time.Now().Format("2006-01-02 15:04:05")
|
||||
entry := fmt.Sprintf("[%s] INFO: %s\n", timestamp, message)
|
||||
l.infoFile.WriteString(entry)
|
||||
l.writeStructured("info", message, "", "")
|
||||
}
|
||||
|
||||
func (l *Logger) Error(message, err string) {
|
||||
timestamp := time.Now().Format("2006-01-02 15:04:05")
|
||||
entry := fmt.Sprintf("[%s] ERROR: %s - %s\n", timestamp, message, err)
|
||||
l.errorFile.WriteString(entry)
|
||||
l.writeStructured("error", message, err, "")
|
||||
}
|
||||
|
||||
func (l *Logger) ErrorFile(file string, message, err string) {
|
||||
timestamp := time.Now().Format("2006-01-02 15:04:05")
|
||||
entry := fmt.Sprintf("[%s] ERROR: %s - %s (file: %s)\n", timestamp, message, err, file)
|
||||
l.errorFile.WriteString(entry)
|
||||
l.writeStructured("error", message, err, file)
|
||||
}
|
||||
|
||||
func (l *Logger) writeStructured(level, message, err, file string) {
|
||||
entry := types.LogEntry{
|
||||
Timestamp: time.Now(),
|
||||
Level: level,
|
||||
Message: message,
|
||||
Error: err,
|
||||
File: file,
|
||||
}
|
||||
data, _ := json.Marshal(entry)
|
||||
l.structFile.WriteString(string(data) + "\n")
|
||||
}
|
||||
|
||||
func (l *Logger) Close() {
|
||||
l.infoFile.Close()
|
||||
l.errorFile.Close()
|
||||
l.structFile.Close()
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package mover
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func MoveToOutput(input, outputDir string) error {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating output dir: %w", err)
|
||||
}
|
||||
filename := filepath.Base(input)
|
||||
dest := filepath.Join(outputDir, filename)
|
||||
return os.Rename(input, dest)
|
||||
}
|
||||
|
||||
func MoveToFailed(input, failedDir string) error {
|
||||
if err := os.MkdirAll(failedDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating failed dir: %w", err)
|
||||
}
|
||||
filename := filepath.Base(input)
|
||||
dest := filepath.Join(failedDir, filename)
|
||||
return os.Rename(input, dest)
|
||||
}
|
||||
|
||||
func MoveToOriginals(input, originalsDir string) error {
|
||||
if err := os.MkdirAll(originalsDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating originals dir: %w", err)
|
||||
}
|
||||
filename := filepath.Base(input)
|
||||
dest := filepath.Join(originalsDir, filename)
|
||||
return os.Rename(input, dest)
|
||||
}
|
||||
|
||||
func Delete(path string) error {
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
func Rename(source, dest string) error {
|
||||
return os.Rename(source, dest)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"videnc-vibe/pkg/types"
|
||||
)
|
||||
|
||||
type Watcher struct {
|
||||
inputDir string
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
func New(inputDir string, intervalSeconds int) *Watcher {
|
||||
interval := time.Duration(intervalSeconds) * time.Second
|
||||
if interval < 10*time.Second {
|
||||
interval = 10 * time.Second
|
||||
}
|
||||
if interval > 30*time.Second {
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
return &Watcher{
|
||||
inputDir: inputDir,
|
||||
interval: interval,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) Start(processFn func(string) error, done chan struct{}) {
|
||||
ticker := time.NewTicker(w.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
w.scanAndProcess(processFn)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.scanAndProcess(processFn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) scanAndProcess(processFn func(string) error) {
|
||||
files, err := filepath.Glob(filepath.Join(w.inputDir, "*.mkv"))
|
||||
if err != nil {
|
||||
fmt.Printf("Error scanning input directory: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if err := processFn(file); err != nil {
|
||||
fmt.Printf("Error processing %s: %v\n", file, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func DetectMediaType(width, height int) types.MediaType {
|
||||
pixels := width * height
|
||||
if pixels < 600000 {
|
||||
return types.MediaTypeDVD
|
||||
}
|
||||
return types.MediaTypeBluRay
|
||||
}
|
||||
Reference in New Issue
Block a user