package main import ( "context" "fmt" "log/slog" "os" "os/exec" "os/signal" "path/filepath" "syscall" "time" "vidarchive/internal/config" "vidarchive/internal/database" "vidarchive/internal/handler" "vidarchive/internal/repository" "vidarchive/internal/server" "vidarchive/internal/service" "vidarchive/internal/worker" ) func main() { // All configuration is environment variables (see README). An argument is // a mistake, so fail instead of silently starting the server. if len(os.Args) > 1 { fmt.Fprintf(os.Stderr, "vidarchive takes no arguments; configure it with VIDARCHIVE_* environment variables (got %q)\n", os.Args[1:]) os.Exit(2) } // Cancelled on SIGINT/SIGTERM so the server drains, workers stop, running // yt-dlp children are killed and the database is checkpointed and closed. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() cfg := config.New() cfg.SetupLogging() checkCredentials(cfg) checkDependencies(cfg) for _, dir := range []string{ cfg.DataDir, cfg.LibraryDir, cfg.TempDir, filepath.Join(cfg.DataDir, "archives"), } { if err := os.MkdirAll(dir, 0o755); err != nil { fatal("failed to create directory", "dir", dir, "err", err) } } db, err := database.New(cfg) if err != nil { fatal("failed to initialize database", "err", err) } presetRepo := repository.NewPresetRepository(db) downloadRepo := repository.NewDownloadRepository(db) settingsRepo := repository.NewSettingsRepository(db) subscriptionRepo := repository.NewSubscriptionRepository(db) presetSvc := service.NewPresetService(presetRepo) librarySvc := service.NewLibraryService(cfg.LibraryDir, cfg.FFmpegPath, cfg.FFprobePath) settingsSvc := service.NewSettingsService(settingsRepo) subscriptionSvc := service.NewSubscriptionService(subscriptionRepo, cfg) downloadSvc := service.NewDownloadService(downloadRepo, librarySvc, presetSvc, settingsSvc, subscriptionSvc, cfg) workerPool := worker.New(downloadSvc, cfg.Workers) workerPool.Start() scheduler := worker.NewScheduler(subscriptionSvc, downloadSvc, workerPool, time.Duration(cfg.SchedulerInterval)*time.Second) scheduler.Start() h, err := handler.New(cfg, presetSvc, downloadSvc, librarySvc, settingsSvc, subscriptionSvc, workerPool) if err != nil { fatal("failed to initialize handler", "err", err) } srv := server.New(cfg, h) slog.Info("VidArchive starting", "data_dir", cfg.DataDir, "library_dir", cfg.LibraryDir, "temp_dir", cfg.TempDir, "user", cfg.Username) srvErr := srv.Start(ctx) // Shut down in dependency order: stop scheduling new runs, then stop the // workers (killing any running yt-dlp), then checkpoint and close the DB. // This runs on both a signal and a server error, so nothing is left orphaned. slog.Info("shutting down") scheduler.Stop() workerPool.Stop() if err := database.Checkpoint(db); err != nil { slog.Warn("database checkpoint failed", "err", err) } if err := db.Close(); err != nil { slog.Warn("failed to close database", "err", err) } slog.Info("shutdown complete") if srvErr != nil { fatal("server error", "err", srvErr) } } // fatal logs a startup or shutdown failure and exits. slog has no Fatal, and // log.Fatalf would report it at info level once slog owns the log package. func fatal(msg string, args ...any) { slog.Error(msg, args...) os.Exit(1) } // hashHint shows the operator how to generate a usable hash. htpasswd ships // with Apache's tools (apache2-utils on Debian, httpd-tools on Fedora). const hashHint = `htpasswd -bnBC 12 "" 'your-password' | tr -d ':\n'` // checkCredentials refuses to start without a usable login. A hash that only // fails at the login form would look like a forgotten password rather than a // misconfiguration. func checkCredentials(cfg *config.Config) { if cfg.Username == "" || cfg.PasswordHash == "" { fatal("VIDARCHIVE_USERNAME and VIDARCHIVE_PASSWORD_HASH are required", "hint", hashHint) } if err := handler.ValidatePasswordHash(cfg.PasswordHash); err != nil { fatal("VIDARCHIVE_PASSWORD_HASH is not usable", "err", err, "hint", hashHint) } } // checkDependencies warns (loudly, but without aborting) when an external binary // VidArchive relies on isn't on PATH, so a missing yt-dlp/ffmpeg surfaces as a // clear startup message rather than an opaque per-download failure later. func checkDependencies(cfg *config.Config) { deps := []struct{ label, path string }{ {"yt-dlp", cfg.YTDLPPath}, {"ffmpeg", cfg.FFmpegPath}, {"ffprobe", cfg.FFprobePath}, } for _, dep := range deps { if _, err := exec.LookPath(dep.path); err != nil { slog.Warn("dependency not found in PATH, related features will fail until it is installed", "dependency", dep.label, "path", dep.path) } } }