package handler import ( "context" "encoding/json" "maps" "net/http" "slices" "strings" "sync" "time" "vidarchive/internal/util" ) // healthProbeTimeout keeps the endpoint answering promptly with a wedged tool. const healthProbeTimeout = 3 * time.Second // toolCacheTTL: /healthz is public and each lookup forks a process, so without a // cache anyone who reaches the port costs the host three processes per request. const toolCacheTTL = time.Minute // toolRetryTTL applies when a probe timed out, which says less about the tool // than a real answer. Still cached, or a wedged tool hands an unauthenticated // caller three forks per request. const toolRetryTTL = 10 * time.Second // toolCache holds the last 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 ttl time.Duration results map[string]string } type healthResponse struct { Status string `json:"status"` Dependencies map[string]string `json:"dependencies"` } // Health returns 503 only when the database is unreachable, so a container // healthcheck can act on it. A missing yt-dlp or ffmpeg is reported but passes: // the UI still works, only downloads are affected. func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { // An orchestrator reads a stuck request as "still starting", so the whole // probe is bounded. ctx, cancel := context.WithTimeout(r.Context(), healthProbeTimeout) defer cancel() resp := healthResponse{Status: "ok", Dependencies: make(map[string]string, 4)} // The database goes first because it alone decides the status code, and it // must get the timeout budget before a wedged tool spends it. Otherwise a slow // yt-dlp gets the container restarted for a database that was fine. 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) } 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) < h.tools.ttl { 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"), } ttl := toolCacheTTL if slices.Contains(slices.Collect(maps.Values(results)), "timed out") { ttl = toolRetryTTL } h.tools.results, h.tools.at, h.tools.ttl = results, time.Now(), ttl return results } // toolVersion reports "not installed" for a missing binary and "timed out" when // the tool does not 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 }