health.go
⎇
Raw
1package handler
2
3import (
4 "context"
5 "encoding/json"
6 "maps"
7 "net/http"
8 "slices"
9 "strings"
10 "sync"
11 "time"
12 "unicode"
13
14 "vidarchive/internal/tools"
15 "vidarchive/internal/util"
16)
17
18// healthProbeTimeout keeps the endpoint answering promptly with a wedged tool.
19const healthProbeTimeout = 3 * time.Second
20
21// toolCacheTTL: /healthz is public and each lookup forks a process, so without a
22// cache anyone who reaches the port costs the host three processes per request.
23const toolCacheTTL = time.Minute
24
25// toolRetryTTL applies when a probe timed out, which says less about the tool
26// than a real answer. Still cached, or a wedged tool hands an unauthenticated
27// caller three forks per request.
28const toolRetryTTL = 10 * time.Second
29
30// toolCache holds the last version probe. The mutex is held across the probe
31// itself, so a burst of requests collapses into one set of forks.
32type toolCache struct {
33 mu sync.Mutex
34 at time.Time
35 ttl time.Duration
36 results map[string]string
37}
38
39type healthResponse struct {
40 Status string `json:"status"`
41 Dependencies map[string]string `json:"dependencies"`
42}
43
44// Health returns 503 only when the database is unreachable, so a container
45// healthcheck can act on it. A missing yt-dlp or ffmpeg is reported but passes:
46// the UI still works, only downloads are affected.
47func (h *Handler) Health(w http.ResponseWriter, r *http.Request) {
48 // An orchestrator reads a stuck request as "still starting", so the whole
49 // probe is bounded.
50 ctx, cancel := context.WithTimeout(r.Context(), healthProbeTimeout)
51 defer cancel()
52
53 resp := healthResponse{Status: "ok", Dependencies: make(map[string]string, 4)}
54
55 // The database goes first because it alone decides the status code, and it
56 // must get the timeout budget before a wedged tool spends it. Otherwise a slow
57 // yt-dlp gets the container restarted for a database that was fine.
58 status := http.StatusOK
59 if err := h.downloadSvc.Ping(ctx); err != nil {
60 resp.Status = "unhealthy"
61 resp.Dependencies["database"] = "unreachable"
62 status = http.StatusServiceUnavailable
63 } else {
64 resp.Dependencies["database"] = "ok"
65 }
66
67 for tool, version := range h.toolVersions(ctx) {
68 resp.Dependencies[tool] = version
69 }
70
71 w.Header().Set("Content-Type", "application/json")
72 w.WriteHeader(status)
73 _ = json.NewEncoder(w).Encode(resp)
74}
75
76func (h *Handler) toolVersions(ctx context.Context) map[string]string {
77 h.toolCache.mu.Lock()
78 defer h.toolCache.mu.Unlock()
79
80 if h.toolCache.results != nil && time.Since(h.toolCache.at) < h.toolCache.ttl {
81 return h.toolCache.results
82 }
83
84 // The mutex is held across the probe, so a caller without its own deadline
85 // would block every other one. /healthz would then miss its own timeout
86 // while waiting for the lock.
87 ctx, cancel := context.WithTimeout(ctx, healthProbeTimeout)
88 defer cancel()
89
90 results := map[string]string{
91 "yt-dlp": toolVersion(ctx, h.cfg.YTDLPPath, "--version"),
92 "ffmpeg": toolVersion(ctx, h.cfg.FFmpegPath, "-version"),
93 "ffprobe": toolVersion(ctx, h.cfg.FFprobePath, "-version"),
94 }
95 // The JS runtime is optional. A permanent "not installed" would look like a
96 // fault on a setup that never asked for it.
97 if !h.cfg.DenoManaged || tools.Installed(h.cfg.DenoPath) {
98 results["deno"] = toolVersion(ctx, h.cfg.DenoPath, "--version")
99 }
100 ttl := toolCacheTTL
101 if slices.Contains(slices.Collect(maps.Values(results)), "timed out") {
102 ttl = toolRetryTTL
103 }
104 h.toolCache.results, h.toolCache.at, h.toolCache.ttl = results, time.Now(), ttl
105 return results
106}
107
108// invalidateToolVersions forces the next probe to re-run, so the settings page
109// shows the new version straight after an update instead of the cached old one.
110func (h *Handler) invalidateToolVersions() {
111 h.toolCache.mu.Lock()
112 defer h.toolCache.mu.Unlock()
113 h.toolCache.results = nil
114}
115
116// toolVersion reports "not installed" for a missing binary and "timed out" when
117// the tool does not answer within ctx.
118func toolVersion(ctx context.Context, path, versionArg string) string {
119 out, err := util.KillableCommand(ctx, path, versionArg).Output()
120 if err != nil {
121 if ctx.Err() != nil {
122 return "timed out"
123 }
124 return "not installed"
125 }
126 line, _, _ := strings.Cut(strings.TrimSpace(string(out)), "\n")
127 fields := strings.Fields(line)
128 switch {
129 // ffmpeg prints "ffmpeg version N-x ..."; keep the version token only.
130 case len(fields) >= 3 && fields[1] == "version":
131 return fields[2]
132 // deno prints "deno 2.x.y (release, ...)". The digit check stops some other
133 // second word from being reported as a version.
134 case len(fields) >= 2 && fields[1] != "" && unicode.IsDigit(rune(fields[1][0])):
135 return fields[1]
136 }
137 // yt-dlp prints the bare version.
138 return line
139}
140