package main import ( "bytes" "context" "encoding/json" "io" "net/http" "net/url" "time" ) // ppClient is the thin HTTP wrapper around PhotoPrism's /api/v1. It is // deliberately *not* shared across requests with caching: each handler // forwards the caller's X-Auth-Token, so a single shared http.Client (we // reuse the stdlib default) plus per-call header injection is all we need. type ppClient struct { base string h *http.Client } func newPPClient(base string) *ppClient { return &ppClient{ base: base, h: &http.Client{Timeout: 60 * time.Second}, } } // ppResp is the trimmed projection of an HTTP response that callers // actually consume. Status + raw body are exposed so handlers can mirror // PhotoPrism's status code or parse the body themselves. Header is // retained for callers that need `X-Count` / `X-Limit` / `X-Offset` on // list endpoints — PhotoPrism exposes total-match counts there. type ppResp struct { OK bool Status int Body []byte Header http.Header } // call issues an authenticated request against PhotoPrism. body is // optional; pass nil for GET/DELETE. We don't JSON-decode here — callers // know the shape they want and decode lazily. func (c *ppClient) call(ctx context.Context, method, urlPath, token string, body any) (*ppResp, error) { u, err := url.Parse(c.base) if err != nil { return nil, err } rel, err := url.Parse(urlPath) if err != nil { return nil, err } full := u.ResolveReference(rel).String() var reader io.Reader if body != nil { buf, err := json.Marshal(body) if err != nil { return nil, err } reader = bytes.NewReader(buf) } req, err := http.NewRequestWithContext(ctx, method, full, reader) if err != nil { return nil, err } req.Header.Set("X-Auth-Token", token) req.Header.Set("Content-Type", "application/json") resp, err := c.h.Do(req) if err != nil { return nil, err } defer resp.Body.Close() buf, err := io.ReadAll(resp.Body) if err != nil { return nil, err } return &ppResp{ OK: resp.StatusCode >= 200 && resp.StatusCode < 300, Status: resp.StatusCode, Body: buf, Header: resp.Header, }, nil } // validateSession is the cheapest probe that the supplied token is live: // list one photo. 401 → bad/expired token. We never read the payload. func (c *ppClient) validateSession(ctx context.Context, token string) bool { if token == "" { return false } r, err := c.call(ctx, http.MethodGet, "/api/v1/photos?count=1", token, nil) if err != nil { return false } return r.OK } // reindex tells PhotoPrism to re-walk a single subpath of originals and // reconcile its DB with the on-disk state. Callers fire this after any // rename/create/delete so the timeline catches up. `cleanup: true` drops // orphan rows (e.g. the row for the file's old name after a rename). // // Best-effort: errors are surfaced to the caller, who logs but does not // abort — the file mutation has already happened on disk by the time // reindex runs. func (c *ppClient) reindex(ctx context.Context, token, parentRel string) error { if parentRel == "" { parentRel = "/" } _, err := c.call(ctx, http.MethodPost, "/api/v1/index", token, map[string]any{ "path": parentRel, "rescan": false, "cleanup": true, }) return err }