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, "") }