package handler import ( "context" "encoding/json" "maps" "net/http" "slices" "strings" "sync" "time" "vidarchive/internal/util" ) // healthProbeTimeout caps the external version lookups so the endpoint always // answers promptly, even when a tool is wedged. const healthProbeTimeout = 3 * time.Second // toolCacheTTL bounds how stale the reported versions may be. /healthz is // public and each lookup forks a process, so without a cache anyone who can // reach the port costs the host three processes per request. const toolCacheTTL = time.Minute // toolCache holds the last tool version probe. The mutex is held across the // probe itself, so a burst of requests collapses into one set of forks. type toolCache struct { mu sync.Mutex at time.Time results map[string]string } 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" } for tool, version := range h.toolVersions(ctx) { resp.Dependencies[tool] = version } w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(resp) } // toolVersions returns the external tools' versions, re-probing them at most // once per toolCacheTTL. func (h *Handler) toolVersions(ctx context.Context) map[string]string { h.tools.mu.Lock() defer h.tools.mu.Unlock() if h.tools.results != nil && time.Since(h.tools.at) < toolCacheTTL { return h.tools.results } results := map[string]string{ "yt-dlp": toolVersion(ctx, h.cfg.YTDLPPath, "--version"), "ffmpeg": toolVersion(ctx, h.cfg.FFmpegPath, "-version"), "ffprobe": toolVersion(ctx, h.cfg.FFprobePath, "-version"), } // A timed-out probe says nothing about the tool, so don't cache that verdict. if !slices.Contains(slices.Collect(maps.Values(results)), "timed out") { h.tools.results, h.tools.at = results, time.Now() } return results } // 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, versionArg string) string { out, err := util.KillableCommand(ctx, path, versionArg).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 }