package handler import ( "context" "encoding/json" "maps" "net/http" "slices" "strings" "sync" "time" "unicode" "vidarchive/internal/tools" "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.toolCache.mu.Lock() defer h.toolCache.mu.Unlock() if h.toolCache.results != nil && time.Since(h.toolCache.at) < h.toolCache.ttl { return h.toolCache.results } // The mutex is held across the probe, so a caller without its own deadline // would block every other one. /healthz would then miss its own timeout // while waiting for the lock. ctx, cancel := context.WithTimeout(ctx, healthProbeTimeout) defer cancel() 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"), } // The JS runtime is optional. A permanent "not installed" would look like a // fault on a setup that never asked for it. if !h.cfg.DenoManaged || tools.Installed(h.cfg.DenoPath) { results["deno"] = toolVersion(ctx, h.cfg.DenoPath, "--version") } ttl := toolCacheTTL if slices.Contains(slices.Collect(maps.Values(results)), "timed out") { ttl = toolRetryTTL } h.toolCache.results, h.toolCache.at, h.toolCache.ttl = results, time.Now(), ttl return results } // invalidateToolVersions forces the next probe to re-run, so the settings page // shows the new version straight after an update instead of the cached old one. func (h *Handler) invalidateToolVersions() { h.toolCache.mu.Lock() defer h.toolCache.mu.Unlock() h.toolCache.results = nil } // 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") fields := strings.Fields(line) switch { // ffmpeg prints "ffmpeg version N-x ..."; keep the version token only. case len(fields) >= 3 && fields[1] == "version": return fields[2] // deno prints "deno 2.x.y (release, ...)". The digit check stops some other // second word from being reported as a version. case len(fields) >= 2 && fields[1] != "" && unicode.IsDigit(rune(fields[1][0])): return fields[1] } // yt-dlp prints the bare version. return line }