Adds a per-job scratch directory under the new `paths.work` config (default `./work`) so audio.<n>.wav, audio.<n>.opus and output.mkv no longer live beside the user's sources in paths.input. The work subdir is named after the input basename and unconditionally removed when processFile returns (success or failure), which kills bug #4 (intermediates leaking into the user-owned input folder; output.mkv getting re-picked by the 15-second watcher tick on rename failure; fixed-name collision risk for any future concurrency). Tightens the failure paths in main.processFile too (bug #25): - mover.MoveToFailed return values are now surfaced via a new failToFailed helper. - The helper os.Stats the source first; missing -> log and skip instead of the previous silent no-op when MoveToFailed was called on output.mkv before it existed. - On rename-output failure, the source is now routed to paths.failed (it was previously left in paths.input, causing an infinite re-encode loop on the next watcher tick). The old MoveToFailed on the work-dir output is dropped — the deferred RemoveAll covers it. Mechanical changes: - PathsConfig gains `Work string \`yaml:"work"\`` with default ./work, included in EnsureDirs. - Encoder.Transcode signature now takes workDir; extractAudio, encodeOpus and encodeVideo all write into workDir. The internal cleanupWavs/cleanupOpus defers are gone (RemoveAll in main is the one cleanup path). - MANUAL.md updated: example config, field reference, §6 pipeline step wording, §9 failure handling description, §11 runtime directories block.
229 lines
6.4 KiB
Go
229 lines
6.4 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"syscall"
|
|
|
|
"videnc-vibe/internal/config"
|
|
"videnc-vibe/internal/encoder"
|
|
"videnc-vibe/internal/logger"
|
|
"videnc-vibe/internal/metadata"
|
|
"videnc-vibe/internal/mover"
|
|
"videnc-vibe/internal/watcher"
|
|
"videnc-vibe/pkg/types"
|
|
)
|
|
|
|
var (
|
|
deleteOrigin bool
|
|
configPath string
|
|
)
|
|
|
|
func init() {
|
|
flag.BoolVar(&deleteOrigin, "d", false, "Delete original after successful encode")
|
|
flag.StringVar(&configPath, "c", "", "Config file path (default: ~/.config/videnc-vibe/config.yaml)")
|
|
}
|
|
|
|
func main() {
|
|
flag.Parse()
|
|
|
|
cfg, err := config.Load(configPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if err := config.EnsureDirs(cfg); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Failed to create directories: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
log, err := logger.New(".")
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer log.Close()
|
|
|
|
enc := encoder.New()
|
|
if err := enc.CheckDeps(); err != nil {
|
|
log.Error("Dependency check failed", err.Error())
|
|
fmt.Fprintf(os.Stderr, "Dependency check failed: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
metaClient := metadata.NewClient(cfg.OMDBAPIKey)
|
|
|
|
done := make(chan struct{})
|
|
sigChan := make(chan os.Signal, 1)
|
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
go func() {
|
|
<-sigChan
|
|
close(done)
|
|
}()
|
|
|
|
w := watcher.New(cfg.Paths.Input, 15)
|
|
w.Start(func(inputPath string) error {
|
|
return processFile(inputPath, cfg, enc, metaClient, log)
|
|
}, done)
|
|
|
|
log.Info("videnc-vibe started")
|
|
}
|
|
|
|
func processFile(inputPath string, cfg *types.Config, enc *encoder.Encoder, metaClient *metadata.Client, log *logger.Logger) error {
|
|
log.Info(fmt.Sprintf("Processing: %s", inputPath))
|
|
|
|
filename := filepath.Base(inputPath)
|
|
isSeries, imdbID, tvmazeID, season, episode := metadata.ParseFilename(filename)
|
|
|
|
// Per-job work subdirectory under paths.work. Uses the source base name
|
|
// (without ".mkv") as the subdir name. MkdirAll is idempotent, so a
|
|
// leftover dir from a crashed previous run is harmless to overwrite.
|
|
workDirName := strings.TrimSuffix(filename, filepath.Ext(filename))
|
|
workDir := filepath.Join(cfg.Paths.Work, workDirName)
|
|
if err := os.MkdirAll(workDir, 0755); err != nil {
|
|
log.ErrorFile(inputPath, "Creating work directory", err.Error())
|
|
failToFailed(inputPath, cfg.Paths.Failed, log)
|
|
return err
|
|
}
|
|
// Cleanup the work directory on every exit path, success or failure.
|
|
defer os.RemoveAll(workDir)
|
|
|
|
width, height, interlaced, err := enc.GetMediaInfo(inputPath)
|
|
if err != nil {
|
|
log.ErrorFile(inputPath, "Getting media info", err.Error())
|
|
failToFailed(inputPath, cfg.Paths.Failed, log)
|
|
return err
|
|
}
|
|
|
|
streamLangs, err := enc.GetStreamLanguages(inputPath)
|
|
if err != nil {
|
|
log.ErrorFile(inputPath, "Getting stream languages", err.Error())
|
|
}
|
|
|
|
mediaType := metadata.ParseMediaType(filename)
|
|
if mediaType == "" {
|
|
mediaType = watcher.DetectMediaType(width, height)
|
|
}
|
|
|
|
var profile types.EncodingParams
|
|
switch mediaType {
|
|
case types.MediaTypeBluRay:
|
|
profile = cfg.Encoding.Bluray
|
|
case types.MediaTypeWebDL:
|
|
profile = cfg.Encoding.WebDL
|
|
case types.MediaTypeTVRip:
|
|
profile = cfg.Encoding.TVRip
|
|
default:
|
|
profile = cfg.Encoding.DVD
|
|
}
|
|
crf := profile.CRF
|
|
preset := profile.Preset
|
|
|
|
var meta *types.Metadata
|
|
if isSeries && tvmazeID != "" && season != "" && episode != "" {
|
|
meta, err = metaClient.FetchSeriesMetadata(tvmazeID, season, episode)
|
|
if err != nil {
|
|
log.ErrorFile(inputPath, "Fetching series metadata", err.Error())
|
|
}
|
|
} else if imdbID != "" {
|
|
meta, err = metaClient.FetchMovieMetadata(imdbID)
|
|
if err != nil {
|
|
log.ErrorFile(inputPath, "Fetching movie metadata", err.Error())
|
|
}
|
|
}
|
|
|
|
if meta == nil {
|
|
meta = &types.Metadata{
|
|
Title: "Unknown",
|
|
DateReleased: "",
|
|
IMDBID: "",
|
|
OriginalMedia: mediaType,
|
|
}
|
|
}
|
|
meta.OriginalMedia = mediaType
|
|
|
|
job := &types.Job{
|
|
InputPath: inputPath,
|
|
MediaType: mediaType,
|
|
CRF: crf,
|
|
Preset: preset,
|
|
DeleteOrigin: deleteOrigin,
|
|
}
|
|
|
|
if err := enc.Transcode(inputPath, workDir, job, meta, interlaced, streamLangs); err != nil {
|
|
log.ErrorFile(inputPath, "Transcoding", err.Error())
|
|
failToFailed(inputPath, cfg.Paths.Failed, log)
|
|
return err
|
|
}
|
|
|
|
outputPath := filepath.Join(workDir, "output.mkv")
|
|
|
|
var outFilename string
|
|
switch {
|
|
case isSeries && meta.Collection != "":
|
|
outFilename = fmt.Sprintf("%s.S%sE%s.mkv", sanitizeFilename(meta.Collection), season, episode)
|
|
case !isSeries && meta.IMDBID != "" && meta.Title != "Unknown":
|
|
outFilename = fmt.Sprintf("%s.%s.mkv", sanitizeFilename(meta.Title), meta.IMDBID)
|
|
default:
|
|
outFilename = fmt.Sprintf("%s.nometadata.mkv", generateRandomString(8))
|
|
}
|
|
|
|
finalOutput := filepath.Join(cfg.Paths.Output, outFilename)
|
|
if err := mover.Rename(outputPath, finalOutput); err != nil {
|
|
// Move the source to failed/ so it doesn't get re-encoded on the next
|
|
// tick. The deferred RemoveAll(workDir) takes care of the partial
|
|
// output.mkv left in the work dir.
|
|
log.ErrorFile(inputPath, "Moving output", err.Error())
|
|
failToFailed(inputPath, cfg.Paths.Failed, log)
|
|
return err
|
|
}
|
|
|
|
if deleteOrigin {
|
|
mover.Delete(inputPath)
|
|
log.Info(fmt.Sprintf("Deleted original: %s", inputPath))
|
|
} else {
|
|
mover.MoveToOriginals(inputPath, cfg.Paths.Originals)
|
|
log.Info(fmt.Sprintf("Moved original to originals: %s", inputPath))
|
|
}
|
|
|
|
log.Info(fmt.Sprintf("Completed: %s -> %s", inputPath, finalOutput))
|
|
return nil
|
|
}
|
|
|
|
// failToFailed stats path before invoking MoveToFailed: if missing, logs and
|
|
// skips; if present, surfaces any move error to the logger. This avoids the
|
|
// silent no-op pattern where MoveToFailed was called on a non-existent file.
|
|
func failToFailed(path, failedDir string, log *logger.Logger) {
|
|
if _, err := os.Stat(path); err != nil {
|
|
if os.IsNotExist(err) {
|
|
log.Info(fmt.Sprintf("Skip move to failed; not present: %s", path))
|
|
return
|
|
}
|
|
log.ErrorFile(path, "Stat before move to failed", err.Error())
|
|
return
|
|
}
|
|
if err := mover.MoveToFailed(path, failedDir); err != nil {
|
|
log.ErrorFile(path, "Moving to failed", err.Error())
|
|
}
|
|
}
|
|
|
|
func generateRandomString(length int) string {
|
|
bytes := make([]byte, length/2+1)
|
|
rand.Read(bytes)
|
|
return hex.EncodeToString(bytes)[:length]
|
|
}
|
|
|
|
func sanitizeFilename(name string) string {
|
|
reg := regexp.MustCompile(`[^a-zA-Z0-9\-äöÄÖ]`)
|
|
return reg.ReplaceAllString(name, "")
|
|
}
|