package handler import ( "context" "encoding/json" "net/http" "os/exec" "strings" "syscall" "time" ) // healthProbeTimeout caps the external version lookups so the endpoint always // answers promptly, even when a tool is wedged. const healthProbeTimeout = 3 * time.Second type healthResponse struct { Status string `json:"status"` Dependencies map[string]string `json:"dependencies"` } // Health reports whether the app can reach its database and external tools. It // returns 503 when the database is unreachable, so a container healthcheck or // orchestrator can act on it. A missing yt-dlp/ffmpeg is reported but does not // fail the check: the UI still works, only downloads are affected. func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { // Bound the whole probe: a health check that can hang is worse than useless, // since an orchestrator reads a stuck request as "still starting". ctx, cancel := context.WithTimeout(r.Context(), healthProbeTimeout) defer cancel() resp := healthResponse{Status: "ok", Dependencies: make(map[string]string, 4)} // The database is probed first because it alone decides the status code, and // it must get the budget before a wedged tool can spend it — otherwise a slow // yt-dlp reports the database as unreachable and the container is restarted // for the wrong reason. status := http.StatusOK if err := h.downloadSvc.Ping(ctx); err != nil { resp.Status = "unhealthy" resp.Dependencies["database"] = "unreachable" status = http.StatusServiceUnavailable } else { resp.Dependencies["database"] = "ok" } resp.Dependencies["yt-dlp"] = toolVersion(ctx, h.cfg.YTDLPPath, "--version") resp.Dependencies["ffmpeg"] = toolVersion(ctx, h.cfg.FFmpegPath, "-version") resp.Dependencies["ffprobe"] = toolVersion(ctx, h.cfg.FFprobePath, "-version") w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(resp) } // toolVersion returns the first line of the tool's version output. It reports // "not installed" when the binary is missing and "timed out" when it doesn't // answer within ctx. func toolVersion(ctx context.Context, path string, versionArg string) string { cmd := exec.CommandContext(ctx, path, versionArg) // Kill the whole process group, not just the tool: yt-dlp and ffmpeg spawn // helpers that inherit the output pipe, and Output() blocks reading it until // every holder exits — which would defeat the timeout. WaitDelay is the // backstop for anything that still survives the signal. cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } cmd.WaitDelay = time.Second out, err := cmd.Output() if err != nil { if ctx.Err() != nil { return "timed out" } return "not installed" } line, _, _ := strings.Cut(strings.TrimSpace(string(out)), "\n") // ffmpeg prints "ffmpeg version N-x ..."; keep the version token only. if fields := strings.Fields(line); len(fields) >= 3 && fields[1] == "version" { return fields[2] } return line }