diff --git a/internal/encoder/encoder.go b/internal/encoder/encoder.go new file mode 100644 index 0000000..d3f92e8 --- /dev/null +++ b/internal/encoder/encoder.go @@ -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) + } +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..4789a90 --- /dev/null +++ b/internal/logger/logger.go @@ -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() +} diff --git a/internal/mover/mover.go b/internal/mover/mover.go new file mode 100644 index 0000000..ff0cac0 --- /dev/null +++ b/internal/mover/mover.go @@ -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) +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go new file mode 100644 index 0000000..61f8b13 --- /dev/null +++ b/internal/watcher/watcher.go @@ -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 +}