The queue card now shows an estimated time to clear: current job remaining + sum(pending source durations) / current speed. Durations come from a path+mtime-keyed cache that probes each file once in the background (ffprobe format=duration, header-only), so the 1s poll never re-probes; entries are pruned to the live queue. No speed (idle/held) -> no estimate; files still being probed mark it partial (shown as '~Xh+'). Closes #9
331 lines
10 KiB
Go
331 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"flag"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"av1dae/internal/config"
|
|
"av1dae/internal/encoder"
|
|
"av1dae/internal/logger"
|
|
"av1dae/internal/metadata"
|
|
"av1dae/internal/mover"
|
|
"av1dae/internal/server"
|
|
"av1dae/internal/settings"
|
|
"av1dae/internal/status"
|
|
"av1dae/internal/watcher"
|
|
"av1dae/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/av1dae/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)
|
|
}
|
|
|
|
// Live-editable settings: config.yaml seeds the store on first run; after
|
|
// that the DB (logs.db) is the source of truth for these values.
|
|
store, err := settings.New(log.DB(), settings.Settings{
|
|
DVD: settings.Profile{CRF: cfg.Encoding.DVD.CRF, Preset: cfg.Encoding.DVD.Preset},
|
|
Bluray: settings.Profile{CRF: cfg.Encoding.Bluray.CRF, Preset: cfg.Encoding.Bluray.Preset},
|
|
WebDL: settings.Profile{CRF: cfg.Encoding.WebDL.CRF, Preset: cfg.Encoding.WebDL.Preset},
|
|
TVRip: settings.Profile{CRF: cfg.Encoding.TVRip.CRF, Preset: cfg.Encoding.TVRip.Preset},
|
|
LP: cfg.Encoding.LP,
|
|
OMDBAPIKey: cfg.OMDBAPIKey,
|
|
LogRetentionDays: cfg.LogRetentionDays,
|
|
DeleteOriginals: deleteOrigin,
|
|
})
|
|
if err != nil {
|
|
log.Error("Settings init failed", err.Error())
|
|
fmt.Fprintf(os.Stderr, "Failed to init settings: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
// A persisted retention value (DB) overrides the config-seeded one.
|
|
log.SetRetention(store.Get().LogRetentionDays)
|
|
|
|
metaClient := metadata.NewClient(cfg.OMDBAPIKey, log)
|
|
|
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer cancel()
|
|
|
|
// Start/hold gate: held by default so the user clicks Start; AV1DAE_AUTOSTART
|
|
// (truthy) restores start-on-boot.
|
|
autostart := envTruthy(os.Getenv("AV1DAE_AUTOSTART"))
|
|
w := watcher.New(cfg.Paths.Input, 15, autostart)
|
|
if !autostart {
|
|
log.Info("Queue held on startup — click Start (set AV1DAE_AUTOSTART=1 to auto-start)")
|
|
}
|
|
|
|
controls := server.Controls{
|
|
Running: w.Running,
|
|
SetRunning: w.SetRunning,
|
|
Pause: enc.Pause,
|
|
Resume: enc.Resume,
|
|
RetryFailed: func(name string) error {
|
|
if name == "" || name != filepath.Base(name) {
|
|
return fmt.Errorf("invalid file name")
|
|
}
|
|
return mover.Rename(filepath.Join(cfg.Paths.Failed, name), filepath.Join(cfg.Paths.Input, name))
|
|
},
|
|
}
|
|
|
|
if addr := *cfg.HTTPAddr; addr != "" {
|
|
probeDuration := func(p string) (float64, error) {
|
|
pctx, pcancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer pcancel()
|
|
return enc.GetDuration(pctx, p)
|
|
}
|
|
srv := &http.Server{Addr: addr, Handler: server.New(tracker, log, store, controls, probeDuration, cfg.Paths.Input, cfg.Paths.Failed).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.Start(ctx, func(ctx context.Context, inputPath string) error {
|
|
return processFile(ctx, inputPath, cfg, enc, metaClient, log, tracker, store)
|
|
})
|
|
|
|
log.Info("av1dae started")
|
|
}
|
|
|
|
func processFile(ctx context.Context, inputPath string, cfg *types.Config, enc *encoder.Encoder, metaClient *metadata.Client, log *logger.Logger, tracker *status.Tracker, store *settings.Store) error {
|
|
log.Info(fmt.Sprintf("Processing: %s", inputPath))
|
|
|
|
// Snapshot the live settings once for the whole job, so a save mid-encode
|
|
// doesn't change anything until the next file.
|
|
cur := store.Get()
|
|
metaClient.SetAPIKey(cur.OMDBAPIKey)
|
|
|
|
// 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)
|
|
}
|
|
|
|
profile := cur.ProfileFor(mediaType)
|
|
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,
|
|
LP: cur.LP,
|
|
DeleteOrigin: cur.DeleteOriginals,
|
|
}
|
|
|
|
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 cur.DeleteOriginals {
|
|
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())
|
|
}
|
|
}
|
|
|
|
// envTruthy reports whether an env var is set to a truthy value.
|
|
func envTruthy(s string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
|
case "1", "true", "yes", "on":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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, "")
|
|
}
|