main.go
| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "log/slog" |
| 7 | "os" |
| 8 | "os/exec" |
| 9 | "os/signal" |
| 10 | "path/filepath" |
| 11 | "syscall" |
| 12 | "time" |
| 13 | |
| 14 | "vidarchive/internal/config" |
| 15 | "vidarchive/internal/database" |
| 16 | "vidarchive/internal/handler" |
| 17 | "vidarchive/internal/repository" |
| 18 | "vidarchive/internal/server" |
| 19 | "vidarchive/internal/service" |
| 20 | "vidarchive/internal/worker" |
| 21 | ) |
| 22 | |
| 23 | func main() { |
| 24 | // All configuration is environment variables (see README). An argument is |
| 25 | // a mistake, so fail instead of silently starting the server. |
| 26 | if len(os.Args) > 1 { |
| 27 | fmt.Fprintf(os.Stderr, "vidarchive takes no arguments; configure it with VIDARCHIVE_* environment variables (got %q)\n", os.Args[1:]) |
| 28 | os.Exit(2) |
| 29 | } |
| 30 | |
| 31 | // Cancelled on SIGINT/SIGTERM so the server drains, workers stop, running |
| 32 | // yt-dlp children are killed and the database is checkpointed and closed. |
| 33 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) |
| 34 | defer stop() |
| 35 | |
| 36 | cfg := config.New() |
| 37 | cfg.SetupLogging() |
| 38 | |
| 39 | checkCredentials(cfg) |
| 40 | checkDependencies(cfg) |
| 41 | |
| 42 | for _, dir := range []string{ |
| 43 | cfg.DataDir, |
| 44 | cfg.LibraryDir, |
| 45 | cfg.TempDir, |
| 46 | filepath.Join(cfg.DataDir, "archives"), |
| 47 | } { |
| 48 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 49 | fatal("failed to create directory", "dir", dir, "err", err) |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | db, err := database.New(cfg) |
| 54 | if err != nil { |
| 55 | fatal("failed to initialize database", "err", err) |
| 56 | } |
| 57 | |
| 58 | presetRepo := repository.NewPresetRepository(db) |
| 59 | downloadRepo := repository.NewDownloadRepository(db) |
| 60 | settingsRepo := repository.NewSettingsRepository(db) |
| 61 | subscriptionRepo := repository.NewSubscriptionRepository(db) |
| 62 | |
| 63 | presetSvc := service.NewPresetService(presetRepo) |
| 64 | librarySvc := service.NewLibraryService(cfg.LibraryDir, cfg.FFmpegPath, cfg.FFprobePath) |
| 65 | settingsSvc := service.NewSettingsService(settingsRepo) |
| 66 | subscriptionSvc := service.NewSubscriptionService(subscriptionRepo, cfg) |
| 67 | downloadSvc := service.NewDownloadService(downloadRepo, librarySvc, presetSvc, settingsSvc, subscriptionSvc, cfg) |
| 68 | |
| 69 | workerPool := worker.New(downloadSvc, cfg.Workers) |
| 70 | workerPool.Start() |
| 71 | |
| 72 | scheduler := worker.NewScheduler(subscriptionSvc, downloadSvc, workerPool, time.Duration(cfg.SchedulerInterval)*time.Second) |
| 73 | scheduler.Start() |
| 74 | |
| 75 | h, err := handler.New(cfg, presetSvc, downloadSvc, librarySvc, settingsSvc, subscriptionSvc, workerPool) |
| 76 | if err != nil { |
| 77 | fatal("failed to initialize handler", "err", err) |
| 78 | } |
| 79 | |
| 80 | srv := server.New(cfg, h) |
| 81 | |
| 82 | slog.Info("VidArchive starting", |
| 83 | "data_dir", cfg.DataDir, "library_dir", cfg.LibraryDir, "temp_dir", cfg.TempDir, |
| 84 | "user", cfg.Username) |
| 85 | |
| 86 | srvErr := srv.Start(ctx) |
| 87 | |
| 88 | // Shut down in dependency order: stop scheduling new runs, then stop the |
| 89 | // workers (killing any running yt-dlp), then checkpoint and close the DB. |
| 90 | // This runs on both a signal and a server error, so nothing is left orphaned. |
| 91 | slog.Info("shutting down") |
| 92 | scheduler.Stop() |
| 93 | workerPool.Stop() |
| 94 | if err := database.Checkpoint(db); err != nil { |
| 95 | slog.Warn("database checkpoint failed", "err", err) |
| 96 | } |
| 97 | if err := db.Close(); err != nil { |
| 98 | slog.Warn("failed to close database", "err", err) |
| 99 | } |
| 100 | slog.Info("shutdown complete") |
| 101 | |
| 102 | if srvErr != nil { |
| 103 | fatal("server error", "err", srvErr) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | // fatal logs a startup or shutdown failure and exits. slog has no Fatal, and |
| 108 | // log.Fatalf would report it at info level once slog owns the log package. |
| 109 | func fatal(msg string, args ...any) { |
| 110 | slog.Error(msg, args...) |
| 111 | os.Exit(1) |
| 112 | } |
| 113 | |
| 114 | // hashHint shows the operator how to generate a usable hash. htpasswd ships |
| 115 | // with Apache's tools (apache2-utils on Debian, httpd-tools on Fedora). |
| 116 | const hashHint = `htpasswd -bnBC 12 "" 'your-password' | tr -d ':\n'` |
| 117 | |
| 118 | // checkCredentials refuses to start without a usable login. A hash that only |
| 119 | // fails at the login form would look like a forgotten password rather than a |
| 120 | // misconfiguration. |
| 121 | func checkCredentials(cfg *config.Config) { |
| 122 | if cfg.Username == "" || cfg.PasswordHash == "" { |
| 123 | fatal("VIDARCHIVE_USERNAME and VIDARCHIVE_PASSWORD_HASH are required", "hint", hashHint) |
| 124 | } |
| 125 | if err := handler.ValidatePasswordHash(cfg.PasswordHash); err != nil { |
| 126 | fatal("VIDARCHIVE_PASSWORD_HASH is not usable", "err", err, "hint", hashHint) |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | // checkDependencies warns (loudly, but without aborting) when an external binary |
| 131 | // VidArchive relies on isn't on PATH, so a missing yt-dlp/ffmpeg surfaces as a |
| 132 | // clear startup message rather than an opaque per-download failure later. |
| 133 | func checkDependencies(cfg *config.Config) { |
| 134 | deps := []struct{ label, path string }{ |
| 135 | {"yt-dlp", cfg.YTDLPPath}, |
| 136 | {"ffmpeg", cfg.FFmpegPath}, |
| 137 | {"ffprobe", cfg.FFprobePath}, |
| 138 | } |
| 139 | for _, dep := range deps { |
| 140 | if _, err := exec.LookPath(dep.path); err != nil { |
| 141 | slog.Warn("dependency not found in PATH, related features will fail until it is installed", "dependency", dep.label, "path", dep.path) |
| 142 | } |
| 143 | } |
| 144 | } |
| 145 |