health.go
⎇
Raw
1package handler
2
3import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "os/exec"
8 "strings"
9 "syscall"
10 "time"
11)
12
13// healthProbeTimeout caps the external version lookups so the endpoint always
14// answers promptly, even when a tool is wedged.
15const healthProbeTimeout = 3 * time.Second
16
17type healthResponse struct {
18 Status string `json:"status"`
19 Dependencies map[string]string `json:"dependencies"`
20}
21
22// Health reports whether the app can reach its database and external tools. It
23// returns 503 when the database is unreachable, so a container healthcheck or
24// orchestrator can act on it. A missing yt-dlp/ffmpeg is reported but does not
25// fail the check: the UI still works, only downloads are affected.
26func (h *Handler) Health(w http.ResponseWriter, r *http.Request) {
27 // Bound the whole probe: a health check that can hang is worse than useless,
28 // since an orchestrator reads a stuck request as "still starting".
29 ctx, cancel := context.WithTimeout(r.Context(), healthProbeTimeout)
30 defer cancel()
31
32 resp := healthResponse{Status: "ok", Dependencies: make(map[string]string, 4)}
33
34 // The database is probed first because it alone decides the status code, and
35 // it must get the budget before a wedged tool can spend it — otherwise a slow
36 // yt-dlp reports the database as unreachable and the container is restarted
37 // for the wrong reason.
38 status := http.StatusOK
39 if err := h.downloadSvc.Ping(ctx); err != nil {
40 resp.Status = "unhealthy"
41 resp.Dependencies["database"] = "unreachable"
42 status = http.StatusServiceUnavailable
43 } else {
44 resp.Dependencies["database"] = "ok"
45 }
46
47 resp.Dependencies["yt-dlp"] = toolVersion(ctx, h.cfg.YTDLPPath, "--version")
48 resp.Dependencies["ffmpeg"] = toolVersion(ctx, h.cfg.FFmpegPath, "-version")
49 resp.Dependencies["ffprobe"] = toolVersion(ctx, h.cfg.FFprobePath, "-version")
50
51 w.Header().Set("Content-Type", "application/json")
52 w.WriteHeader(status)
53 _ = json.NewEncoder(w).Encode(resp)
54}
55
56// toolVersion returns the first line of the tool's version output. It reports
57// "not installed" when the binary is missing and "timed out" when it doesn't
58// answer within ctx.
59func toolVersion(ctx context.Context, path string, versionArg string) string {
60 cmd := exec.CommandContext(ctx, path, versionArg)
61 // Kill the whole process group, not just the tool: yt-dlp and ffmpeg spawn
62 // helpers that inherit the output pipe, and Output() blocks reading it until
63 // every holder exits — which would defeat the timeout. WaitDelay is the
64 // backstop for anything that still survives the signal.
65 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
66 cmd.Cancel = func() error {
67 return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
68 }
69 cmd.WaitDelay = time.Second
70
71 out, err := cmd.Output()
72 if err != nil {
73 if ctx.Err() != nil {
74 return "timed out"
75 }
76 return "not installed"
77 }
78 line, _, _ := strings.Cut(strings.TrimSpace(string(out)), "\n")
79 // ffmpeg prints "ffmpeg version N-x ..."; keep the version token only.
80 if fields := strings.Fields(line); len(fields) >= 3 && fields[1] == "version" {
81 return fields[2]
82 }
83 return line
84}
85