Files
av1dae/cmd/videnc/main.go
T
Esa Kataja 35193c695e add: show metadata, streams, and clock-time ETA in status UI
Surface the fetched job metadata (show/episode or movie title, season,
episode, release/airdate, media type) and the source audio/subtitle streams
through the tracker and /status, and render them in the dashboard's current-job
card. Audio channel counts are now probed and shown as 5.1/2.0/etc.

ETA now reads as a wall-clock finish time plus remaining duration, e.g.
"17:49 (3h52m)". Recent events drop the per-row date for a WhatsApp-style
Today/Yesterday/date divider with time-only rows. All dynamic strings are
HTML-escaped, since metadata and filenames are user-controlled.
2026-06-21 18:23:36 +03:00

280 lines
8.4 KiB
Go

package main
import (
"context"
"crypto/rand"
"encoding/hex"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"path/filepath"
"regexp"
"strings"
"syscall"
"time"
"videnc-vibe/internal/config"
"videnc-vibe/internal/encoder"
"videnc-vibe/internal/logger"
"videnc-vibe/internal/metadata"
"videnc-vibe/internal/mover"
"videnc-vibe/internal/server"
"videnc-vibe/internal/status"
"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(".", cfg.LogRetentionDays)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err)
os.Exit(1)
}
defer log.Close()
tracker := status.New()
enc := encoder.New(log, tracker)
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, log)
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
if addr := *cfg.HTTPAddr; addr != "" {
srv := &http.Server{Addr: addr, Handler: server.New(tracker, log, cfg.Paths.Input).Handler()}
go func() {
log.Info(fmt.Sprintf("Status server listening on %s", addr))
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Error("Status server", err.Error())
}
}()
go func() {
<-ctx.Done()
shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutCancel()
_ = srv.Shutdown(shutCtx)
}()
}
w := watcher.New(cfg.Paths.Input, 15)
w.Start(ctx, func(ctx context.Context, inputPath string) error {
return processFile(ctx, inputPath, cfg, enc, metaClient, log, tracker)
})
log.Info("videnc-vibe started")
}
func processFile(ctx context.Context, inputPath string, cfg *types.Config, enc *encoder.Encoder, metaClient *metadata.Client, log *logger.Logger, tracker *status.Tracker) error {
log.Info(fmt.Sprintf("Processing: %s", inputPath))
// Mark this file as the active job (phase: probing) and clear the tracker
// back to idle on every exit path — success, failure, or cancellation.
tracker.Begin(inputPath)
defer tracker.Idle()
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(ctx, inputPath)
if err != nil {
log.ErrorFile(inputPath, "Getting media info", err.Error())
failToFailed(inputPath, cfg.Paths.Failed, log)
return err
}
streamLangs, err := enc.GetStreamLanguages(ctx, inputPath)
if err != nil {
log.ErrorFile(inputPath, "Getting stream languages", err.Error())
}
tracker.SetStreams(toStatusStreams(streamLangs))
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(ctx, tvmazeID, season, episode)
if err != nil {
log.ErrorFile(inputPath, "Fetching series metadata", err.Error())
} else if meta != nil {
log.Info(fmt.Sprintf("TVmaze hit: %s S%sE%s - %s", meta.Collection, meta.Season, meta.Episode, meta.Title))
}
} else if imdbID != "" {
meta, err = metaClient.FetchMovieMetadata(ctx, imdbID)
if err != nil {
log.ErrorFile(inputPath, "Fetching movie metadata", err.Error())
} else if meta != nil {
log.Info(fmt.Sprintf("OMDb hit: %s (%s)", meta.Title, meta.IMDBID))
}
}
if meta == nil {
meta = &types.Metadata{
Title: "Unknown",
DateReleased: "",
IMDBID: "",
OriginalMedia: mediaType,
}
}
meta.OriginalMedia = mediaType
tracker.SetMeta(status.JobMeta{
IsSeries: meta.IsSeries,
Title: meta.Title,
Collection: meta.Collection,
Season: meta.Season,
Episode: meta.Episode,
DateReleased: meta.DateReleased,
MediaType: string(mediaType),
})
job := &types.Job{
InputPath: inputPath,
MediaType: mediaType,
CRF: crf,
Preset: preset,
DeleteOrigin: deleteOrigin,
}
if err := enc.Transcode(ctx, 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())
}
}
// toStatusStreams maps probed source streams to the display shape, keeping only
// audio and subtitle streams (the video stream isn't shown).
func toStatusStreams(streams []encoder.StreamMetadata) []status.Stream {
var out []status.Stream
for _, s := range streams {
switch s.CodecType {
case "audio":
out = append(out, status.Stream{Kind: "audio", Language: s.Language, Codec: s.CodecName, Channels: s.Channels, Title: s.Title})
case "subtitle":
out = append(out, status.Stream{Kind: "subtitle", Language: s.Language, Codec: s.CodecName, Title: s.Title})
}
}
return out
}
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, "")
}