From 1fd00a00c2aa648425c688881e21cd2ea3f9e965 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 19 Sep 2026 11:33:22 +0000 Subject: [PATCH 1/4] docs: expand motivation section to include AI-agent support for image management --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index f51d922..8f17f68 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ I want to be able to manage my Immich server from the command line for my large photo collection. There are some bulk features that are not available in the web interface, and I want to be able to automate some tasks. Like compression, converting, repairing, and deleting photos. +Another motivation is managing an overwhelming number of images with AI-agent support: the generated agent skill (`immich-admin-cli/SKILL.md`) describes every command for AI consumption, and the CLI surface itself is optimized for AI agents (e.g. Hermes Agent) — machine-readable `--json` output, pipeable `--ids-only` IDs, `--dry-run` previews, and non-interactive `--yes` flags. Agents can always retrieve the up-to-date skill straight from the binary via `immich-admin return-agent-skill`, so they never have to work from stale documentation about how the CLI works. + ## Self-Update `immich-admin update` checks the latest [GitHub release](https://github.com/dhcgn/immich-admin-cli/releases) for a build matching the current OS/arch and, on confirmation, replaces the running executable in place and restarts it (via [`gh-update`](https://github.com/dhcgn/gh-update)). From 1b097672a3a1aa1467477fefbb286b460b34a00f Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 19 Sep 2026 20:43:00 +0000 Subject: [PATCH 2/4] feat: add client-workflow find-similar via immich-clip-probe New read-only workflow checks a local image file against the Immich library for visual near-duplicates through the external clip-probe service (POST /v1/similar, x-api-key auth). - config: optional clip_probe.server/token section with IMMICH_CLIP_PROBE_SERVER/TOKEN env overrides; opt-in validation links the clip-probe repo on misconfiguration - internal/clipprobe: multipart client + response types mirroring the service openapi.yaml (limit/max_distance/all/type) - cw find-similar FILE: human, --json and --ids-only/-q output - docs: README section, skill best practices, regenerated SKILL.md --- README.md | 19 +++ config.example.yaml | 15 ++ immich-admin-cli/SKILL.md | 15 ++ internal/clipprobe/client.go | 138 ++++++++++++++++++ internal/clipprobe/client_test.go | 112 ++++++++++++++ internal/clipprobe/types.go | 61 ++++++++ internal/commands/clientworkflow.go | 1 + .../commands/clientworkflow_findsimilar.go | 122 ++++++++++++++++ internal/commands/skill_best_practices.md | 4 + internal/config/config.go | 51 ++++++- internal/config/config_test.go | 57 ++++++++ 11 files changed, 593 insertions(+), 2 deletions(-) create mode 100644 internal/clipprobe/client.go create mode 100644 internal/clipprobe/client_test.go create mode 100644 internal/clipprobe/types.go create mode 100644 internal/commands/clientworkflow_findsimilar.go create mode 100644 internal/config/config_test.go diff --git a/README.md b/README.md index 8f17f68..6d29021 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ All workflows follow the same safety model: `--dry-run` shows what would happen | ✅ done | `client-workflow repair-assets` | Repair corrupt JPEG (missing EOI marker) and TIFF (invalid zero-count IFD tag) assets and re-import them, keeping metadata | | ✅ done | `client-workflow fix-album-dates` | Check assets in date-named albums ("2025-07-04 Garten", "2010 USA") against the date implied by the album name, and offer to fix mismatches | | ✅ done | `client-workflow download-album` | Download all originals or all thumbnails from one album to a local folder, optionally excluding videos, optionally kept in sync | +| ✅ done | `client-workflow find-similar` | Find assets in Immich that look like a local image file (via external clip-probe service) | | ⏳ planned | `client-workflow reencode-jxl` | Re-encode assets to JPEG XL (`cjxl`), then replace the originals | | ⏳ planned | `client-workflow reencode-jpegli` | Re-encode assets with jpegli (`cjpegli`), then replace the originals | @@ -319,6 +320,24 @@ Flags: exactly one of `--album-id UUID` / `--album-name NAME`, `--target-dir DIR The underlying operations are also available standalone: `assets download-original` and `assets download-thumbnail` (`GET /assets/{id}/thumbnail`, supporting `fullsize|preview|thumbnail`, default `preview`, plus `--edited`; `original` is not accepted here — the OpenAPI spec deprecates `size=original` on this endpoint in favor of `assets download-original`). +### `client-workflow find-similar` + +Finds assets already in your Immich library that look like a local image file — before you upload it. Uses the external [immich-clip-probe](https://github.com/dhcgn/immich-clip-probe/) service (runs next to Immich, read-only, token-authenticated). This is the first command that depends on a third-party service: configure `clip_probe.server` + `clip_probe.token` in the config file (or `IMMICH_CLIP_PROBE_SERVER` / `IMMICH_CLIP_PROBE_TOKEN` env vars). + +```sh +# Nearest matches, duplicate verdict based on --max-distance (default 0.01, same as Immich's own duplicate default) +immich-admin cw find-similar DSC_2031.jpg + +# Calibrate a threshold: nearest 5 regardless of distance +immich-admin cw find-similar DSC_2031.jpg --all --limit 5 + +# Pipeable IDs + full raw response +immich-admin cw find-similar DSC_2031.jpg --ids-only +immich-admin cw find-similar DSC_2031.jpg --json +``` + +Flags: `--limit 1-100` (default `10`), `--max-distance 0-2` (default `0.01`), `--all` (ignore the cut-off), `--type IMAGE|VIDEO|all` (default `IMAGE`), `--json`, `--ids-only` / `-q`. An empty match list is a normal result (nothing close enough), not an error. + ## Sample Use Cases ### I imported some corrupt images, now I want to replace them, but keep all metadata and albums diff --git a/config.example.yaml b/config.example.yaml index 9b0492d..2734059 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -17,3 +17,18 @@ api_key: YOUR_API_KEY_HERE # # Used by `client-workflow download-album --resize-video-preset`. # # Env var: IMMICH_FFMPEG_PATH # ffmpeg_path: /usr/bin/ffmpeg + +# Optional: external visual-similarity service used by +# `client-workflow find-similar`. This is the first command that depends on a +# third-party service running next to Immich — setup: +# https://github.com/dhcgn/immich-clip-probe/ +# Leave unset if unused; only `find-similar` requires it. Each field can +# also be set via an environment variable instead (see comments below), +# which takes precedence over this file. +# clip_probe: +# # Origin of the clip-probe service (no /v1 suffix — the client appends +# # /v1/similar). Env var: IMMICH_CLIP_PROBE_SERVER +# server: https://clip-probe.example.com/ +# # This service's own token (API_TOKEN), NOT an Immich API key. +# # Env var: IMMICH_CLIP_PROBE_TOKEN +# token: YOUR_CLIP_PROBE_TOKEN_HERE diff --git a/immich-admin-cli/SKILL.md b/immich-admin-cli/SKILL.md index 8e62cd6..16c2b42 100644 --- a/immich-admin-cli/SKILL.md +++ b/immich-admin-cli/SKILL.md @@ -378,6 +378,17 @@ Move every asset from one album into another, optionally deleting the emptied so - `--dry-run` (bool): print the merge plan without changing anything - `--yes` (bool): skip the confirmation prompt before moving assets +### client-workflow find-similar + +Find assets in Immich that look like a local image file (via clip-probe) +Args: `FILE` +- `--limit` (int): maximum number of matches to return (1-100) [default: 10] +- `--max-distance` (float): cosine-distance cut-off; matches above this are dropped (0-2) [default: 0.01] +- `--all` (bool): return the limit nearest matches regardless of --max-distance (for calibrating a threshold) +- `--type` (string): restrict to a single asset type: IMAGE, VIDEO, or all [default: "IMAGE"] +- `--json` (bool): print the raw clip-probe response as JSON +- `--ids-only, -q` (bool): print only matching asset IDs, one per line + ### client-workflow watch-upload Watch a folder and auto-upload new stable files (POST /assets/bulk-upload-check + POST /assets) @@ -569,6 +580,10 @@ albums separate, or rename instead of merging blindly. Always run `--sidecar`/`--filename` are single-file only; per-file byte bar on stderr, `--quiet` disables, `--json` implies quiet). `assets check-remote-exists FILE|DIR...` (alias `check-bulk-upload`) hashes locally and reports `uploaded ` / `missing` / `unsupported` without mutating (`--json`, `--ids-only -q` pipes duplicate IDs into `albums add-assets`/`tags tag`). +- **Similarity**: `client-workflow find-similar FILE [--all] [--limit N] [--max-distance 0.01] [--type IMAGE] [--json] [-q]` + asks the external clip-probe service (https://github.com/dhcgn/immich-clip-probe/, configured via + `clip_probe.server`/`clip_probe.token` or `IMMICH_CLIP_PROBE_*` env) which library assets look like a local + file before uploading. Empty matches = nothing close enough (not an error). - **Watch**: `client-workflow watch-upload --watch-dir DIR --mode flat|by-subfolder [--tag-pattern "immich-admin-cli/watch/{yyyy-MM-dd}"] [--album-id|--album-name] --interval 60s --stable-for 10s [--once] [--dry-run] [--yes]` polls and uploads only stable files (bulk-check first; duplicates only linked; `--once` forces `--stable-for 0s`, i.e. uploads immediately). A finished folder that nothing is still writing into needs just `--once`; run the loop (no `--once`) only while files are still arriving. diff --git a/internal/clipprobe/client.go b/internal/clipprobe/client.go new file mode 100644 index 0000000..f78706f --- /dev/null +++ b/internal/clipprobe/client.go @@ -0,0 +1,138 @@ +package clipprobe + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "net/url" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "time" +) + +// ValidTypes is the closed set accepted by POST /v1/similar ?type=. +var ValidTypes = []string{"IMAGE", "VIDEO", "all"} + +// Options maps 1:1 to the /v1/similar query parameters. +type Options struct { + Limit int + MaxDistance float64 + All bool + Type string +} + +// DefaultOptions mirrors the service defaults (openapi.yaml). +func DefaultOptions() Options { + return Options{Limit: 10, MaxDistance: 0.01, Type: "IMAGE"} +} + +// Validate rejects values outside the service's closed set before any I/O. +func (o Options) Validate() error { + if o.Limit < 1 || o.Limit > 100 { + return fmt.Errorf("invalid --limit %d: must be between 1 and 100", o.Limit) + } + if o.MaxDistance < 0 || o.MaxDistance > 2 { + return fmt.Errorf("invalid --max-distance %v: must be between 0 and 2", o.MaxDistance) + } + if slices.Contains(ValidTypes, o.Type) { + return nil + } + return fmt.Errorf("invalid --type %q: must be one of %s", o.Type, strings.Join(ValidTypes, ", ")) +} + +// Client talks to one clip-probe service. No Immich credentials are ever +// sent here — the token is the service's own API_TOKEN (x-api-key header). +type Client struct { + server string + token string + http *http.Client +} + +// New builds a Client for the service origin (e.g. https://clip-probe.example.com/). +func New(server, token string) *Client { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.ResponseHeaderTimeout = 30 * time.Second + return &Client{server: strings.TrimRight(server, "/"), token: token, http: &http.Client{Transport: transport}} +} + +// FindSimilar posts one image file and returns the decoded response plus the +// raw body (for --json output). An empty Matches is not an error. +func (c *Client) FindSimilar(ctx context.Context, filePath string, opts Options) (*SimilarResponse, []byte, error) { + if err := opts.Validate(); err != nil { + return nil, nil, err + } + f, err := os.Open(filePath) + if err != nil { + return nil, nil, fmt.Errorf("opening file: %w", err) + } + defer f.Close() + + q := url.Values{} + q.Set("limit", strconv.Itoa(opts.Limit)) + q.Set("max_distance", strconv.FormatFloat(opts.MaxDistance, 'f', -1, 64)) + if opts.All { + q.Set("all", "true") + } + if opts.Type != "" { + q.Set("type", opts.Type) + } + endpoint := c.server + "/v1/similar?" + q.Encode() + + var body bytes.Buffer + w := multipart.NewWriter(&body) + h := textproto.MIMEHeader{} + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, escapeQuotes(filepath.Base(filePath)))) + h.Set("Content-Type", "application/octet-stream") + part, err := w.CreatePart(h) + if err != nil { + return nil, nil, fmt.Errorf("building multipart body: %w", err) + } + if _, err := io.Copy(part, f); err != nil { + return nil, nil, fmt.Errorf("reading file: %w", err) + } + if err := w.Close(); err != nil { + return nil, nil, fmt.Errorf("building multipart body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &body) + if err != nil { + return nil, nil, fmt.Errorf("building request: %w", err) + } + req.Header.Set("Content-Type", w.FormDataContentType()) + req.Header.Set("x-api-key", c.token) + + resp, err := c.http.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("calling POST /v1/similar: %w", err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if err != nil { + return nil, nil, fmt.Errorf("reading response: %w", err) + } + if resp.StatusCode != http.StatusOK { + var eb ErrorBody + if json.Unmarshal(raw, &eb) == nil && eb.Error != "" { + return nil, nil, fmt.Errorf("clip-probe returned %s (%s): %s", resp.Status, eb.Error, eb.Message) + } + return nil, nil, fmt.Errorf("clip-probe returned %s: %s", resp.Status, strings.TrimSpace(string(raw))) + } + var out SimilarResponse + if err := json.Unmarshal(raw, &out); err != nil { + return nil, nil, fmt.Errorf("decoding response: %w", err) + } + return &out, raw, nil +} + +// escapeQuotes keeps a hostile file name inside the multipart header. +func escapeQuotes(s string) string { + return strings.ReplaceAll(s, `"`, "_") +} diff --git a/internal/clipprobe/client_test.go b/internal/clipprobe/client_test.go new file mode 100644 index 0000000..bbdfce4 --- /dev/null +++ b/internal/clipprobe/client_test.go @@ -0,0 +1,112 @@ +package clipprobe + +import ( + "context" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestOptionsValidate(t *testing.T) { + if err := DefaultOptions().Validate(); err != nil { + t.Fatalf("defaults must validate: %v", err) + } + for _, tc := range []Options{ + {Limit: 0, MaxDistance: 0.01, Type: "IMAGE"}, + {Limit: 101, MaxDistance: 0.01, Type: "IMAGE"}, + {Limit: 10, MaxDistance: -0.1, Type: "IMAGE"}, + {Limit: 10, MaxDistance: 2.1, Type: "IMAGE"}, + {Limit: 10, MaxDistance: 0.01, Type: "BOGUS"}, + } { + if err := tc.Validate(); err == nil { + t.Errorf("expected error for %+v", tc) + } + } +} + +func TestFindSimilarRoundtrip(t *testing.T) { + var gotKey, gotQuery, gotFileName string + var gotFileBytes []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotKey = r.Header.Get("x-api-key") + gotQuery = r.URL.RawQuery + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Errorf("parsing multipart: %v", err) + } + f, hdr, err := r.FormFile("file") + if err != nil { + t.Errorf("missing file part: %v", err) + } else { + gotFileName = hdr.Filename + buf := make([]byte, 4) + n, _ := f.Read(buf) + gotFileBytes = buf[:n] + f.Close() + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(SimilarResponse{ + Duplicate: true, + MaxDistance: 0.01, + Query: Query{FileName: "q.jpg", Bytes: 4, Width: 2, Height: 2, Model: "ViT-B-32__openai", Dimensions: 512}, + Matches: []Match{{AssetID: "0421250e-23a9-4a5b-a9ac-23c9bd7cafdb", OriginalFileName: "DSC_2031.jpg", Type: "IMAGE", Distance: 0.0028, Similarity: 0.9971}}, + }) + })) + defer srv.Close() + + dir := t.TempDir() + img := filepath.Join(dir, "q.jpg") + if err := os.WriteFile(img, []byte{0xFF, 0xD8, 0xFF, 0xD9}, 0o644); err != nil { + t.Fatal(err) + } + + c := New(srv.URL, "sekret") + resp, raw, err := c.FindSimilar(context.Background(), img, DefaultOptions()) + if err != nil { + t.Fatalf("FindSimilar: %v", err) + } + if gotKey != "sekret" { + t.Errorf("x-api-key = %q", gotKey) + } + for _, want := range []string{"limit=10", "max_distance=0.01", "type=IMAGE"} { + if !strings.Contains(gotQuery, want) { + t.Errorf("query %q missing %q", gotQuery, want) + } + } + if gotFileName != "q.jpg" { + t.Errorf("filename = %q", gotFileName) + } + if len(gotFileBytes) != 4 { + t.Errorf("file bytes = %d", len(gotFileBytes)) + } + if !resp.Duplicate || len(resp.Matches) != 1 || resp.Matches[0].AssetID != "0421250e-23a9-4a5b-a9ac-23c9bd7cafdb" { + t.Errorf("unexpected response: %+v", resp) + } + var check SimilarResponse + if err := json.Unmarshal(raw, &check); err != nil { + t.Errorf("raw body is not JSON: %v", err) + } +} + +func TestFindSimilarErrorMapping(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = multipart.NewWriter(nil) + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized","message":"bad token"}`)) + })) + defer srv.Close() + + dir := t.TempDir() + img := filepath.Join(dir, "q.jpg") + if err := os.WriteFile(img, []byte{1, 2}, 0o644); err != nil { + t.Fatal(err) + } + _, _, err := New(srv.URL, "wrong").FindSimilar(context.Background(), img, DefaultOptions()) + if err == nil || !strings.Contains(err.Error(), "unauthorized") { + t.Errorf("expected unauthorized error, got %v", err) + } +} diff --git a/internal/clipprobe/types.go b/internal/clipprobe/types.go new file mode 100644 index 0000000..2bd808e --- /dev/null +++ b/internal/clipprobe/types.go @@ -0,0 +1,61 @@ +// Package clipprobe talks to the external immich-clip-probe service +// (https://github.com/dhcgn/immich-clip-probe/), which exposes Immich's +// CLIP similarity search for files that are not in Immich yet. +// It is the only place that does raw HTTP for clip-probe; command and +// workflow code must go through Client. +package clipprobe + +// Query describes what the service actually embedded. +type Query struct { + FileName string `json:"fileName,omitempty"` + Bytes int64 `json:"bytes"` + Width int `json:"width"` + Height int `json:"height"` + Normalized bool `json:"normalized"` + Model string `json:"model"` + Dimensions int `json:"dimensions"` +} + +// Links holds deep links to a match. Absent unless the service is +// configured with PUBLIC_IMMICH_URL. +type Links struct { + Web string `json:"web,omitempty"` + Thumbnail string `json:"thumbnail,omitempty"` +} + +// Match is one visually-nearest asset, ascending by Distance. +type Match struct { + AssetID string `json:"assetId"` + OriginalFileName string `json:"originalFileName"` + Type string `json:"type"` + LocalDateTime string `json:"localDateTime,omitempty"` + FileCreatedAt string `json:"fileCreatedAt,omitempty"` + OwnerID string `json:"ownerId,omitempty"` + Distance float64 `json:"distance"` + Similarity float64 `json:"similarity"` + Links *Links `json:"links,omitempty"` +} + +// Timings holds per-stage wall-clock milliseconds. +type Timings struct { + NormalizeMs int64 `json:"normalizeMs,omitempty"` + EmbedMs int64 `json:"embedMs,omitempty"` + QueryMs int64 `json:"queryMs,omitempty"` + TotalMs int64 `json:"totalMs,omitempty"` +} + +// SimilarResponse is the POST /v1/similar payload. An empty Matches array +// is a normal result (nothing close enough). +type SimilarResponse struct { + Query Query `json:"query"` + Duplicate bool `json:"duplicate"` + MaxDistance float64 `json:"maxDistance"` + Matches []Match `json:"matches"` + Timings *Timings `json:"timings,omitempty"` +} + +// ErrorBody is the service's error shape. Match on Error, never on Message. +type ErrorBody struct { + Error string `json:"error"` + Message string `json:"message"` +} diff --git a/internal/commands/clientworkflow.go b/internal/commands/clientworkflow.go index 7f22c8d..a521817 100644 --- a/internal/commands/clientworkflow.go +++ b/internal/commands/clientworkflow.go @@ -43,6 +43,7 @@ func ClientWorkflow() *cli.Command { fixAlbumDatesCommand(), downloadAlbumCommand(), mergeAlbumCommand(), + findSimilarCommand(), watchUploadCommand(), watchDownloadCommand(), }, diff --git a/internal/commands/clientworkflow_findsimilar.go b/internal/commands/clientworkflow_findsimilar.go new file mode 100644 index 0000000..651ba25 --- /dev/null +++ b/internal/commands/clientworkflow_findsimilar.go @@ -0,0 +1,122 @@ +package commands + +import ( + "context" + "fmt" + "os" + + "github.com/urfave/cli/v3" + + "github.com/dhcgn/immich-admin-cli/internal/clipprobe" + "github.com/dhcgn/immich-admin-cli/internal/config" +) + +func findSimilarCommand() *cli.Command { + return &cli.Command{ + Name: "find-similar", + Usage: "Find assets in Immich that look like a local image file (via clip-probe)", + ArgsUsage: "FILE", + Description: "Submit one local image file to the external immich-clip-probe service " + + "and list the visually-nearest assets already in your Immich library, ordered by " + + "ascending cosine distance. Read-only: nothing is uploaded to Immich.\n" + + "Requires the clip-probe service running next to Immich — setup: " + config.ClipProbeRepoURL + + " Configure clip_probe.server + clip_probe.token in the config file " + + "(or IMMICH_CLIP_PROBE_SERVER / IMMICH_CLIP_PROBE_TOKEN env vars).", + Flags: []cli.Flag{ + &cli.IntFlag{ + Name: "limit", + Usage: "maximum number of matches to return (1-100)", + Value: 10, + }, + &cli.FloatFlag{ + Name: "max-distance", + Usage: "cosine-distance cut-off; matches above this are dropped (0-2)", + Value: 0.01, + }, + &cli.BoolFlag{ + Name: "all", + Usage: "return the limit nearest matches regardless of --max-distance (for calibrating a threshold)", + }, + &cli.StringFlag{ + Name: "type", + Usage: "restrict to a single asset type: IMAGE, VIDEO, or all", + Value: "IMAGE", + }, + &cli.BoolFlag{ + Name: "json", + Usage: "print the raw clip-probe response as JSON", + }, + &cli.BoolFlag{ + Name: "ids-only", + Aliases: []string{"q"}, + Usage: "print only matching asset IDs, one per line", + }, + }, + Action: clientWorkflowFindSimilar, + } +} + +func clientWorkflowFindSimilar(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) != 1 { + return fmt.Errorf("expected exactly 1 positional argument (FILE), got %d", len(args)) + } + file := args[0] + if st, err := os.Stat(file); err != nil { + return fmt.Errorf("checking file %q: %w", file, err) + } else if st.IsDir() { + return fmt.Errorf("not a file: %q", file) + } + + opts := clipprobe.Options{ + Limit: int(cmd.Int("limit")), + MaxDistance: cmd.Float("max-distance"), + All: cmd.Bool("all"), + Type: cmd.String("type"), + } + // Option range checks run inside clip.FindSimilar (clipprobe.Options + // validation); nothing mutates before that, so no pre-check is needed + // here. (Note: do not call a method literally named `Validate` from + // this package — tools/apitable would mistake it for the Immich + // `validate` library operation and miscount API coverage.) + + // Identity line first (same convention as every other command). + if _, err := newClient(ctx, cmd); err != nil { + return err + } + + cfg, err := config.Load(cmd.String("config")) + if err != nil { + return err + } + if err := cfg.ValidateClipProbe(); err != nil { + return err + } + + clip := clipprobe.New(cfg.ClipProbe.Server, cfg.ClipProbe.Token) + resp, raw, err := clip.FindSimilar(ctx, file, opts) + if err != nil { + return err + } + + switch { + case cmd.Bool("json"): + fmt.Println(string(raw)) + case cmd.Bool("ids-only"): + for _, m := range resp.Matches { + fmt.Println(m.AssetID) + } + default: + fmt.Printf("duplicate: %v (maxDistance %.4g, %d match(es))\n", resp.Duplicate, resp.MaxDistance, len(resp.Matches)) + for _, m := range resp.Matches { + fmt.Printf("%s\tdistance=%.4f\tsimilarity=%.4f\t%s\t%s\n", m.AssetID, m.Distance, m.Similarity, m.OriginalFileName, m.LocalDateTime) + if m.Links != nil && m.Links.Web != "" { + fmt.Printf(" %s\n", m.Links.Web) + } + } + if len(resp.Matches) == 0 { + fmt.Println("No similar assets found.") + } + } + return nil +} diff --git a/internal/commands/skill_best_practices.md b/internal/commands/skill_best_practices.md index 62fbfac..18c431e 100644 --- a/internal/commands/skill_best_practices.md +++ b/internal/commands/skill_best_practices.md @@ -118,6 +118,10 @@ albums separate, or rename instead of merging blindly. Always run `--sidecar`/`--filename` are single-file only; per-file byte bar on stderr, `--quiet` disables, `--json` implies quiet). `assets check-remote-exists FILE|DIR...` (alias `check-bulk-upload`) hashes locally and reports `uploaded ` / `missing` / `unsupported` without mutating (`--json`, `--ids-only -q` pipes duplicate IDs into `albums add-assets`/`tags tag`). +- **Similarity**: `client-workflow find-similar FILE [--all] [--limit N] [--max-distance 0.01] [--type IMAGE] [--json] [-q]` + asks the external clip-probe service (https://github.com/dhcgn/immich-clip-probe/, configured via + `clip_probe.server`/`clip_probe.token` or `IMMICH_CLIP_PROBE_*` env) which library assets look like a local + file before uploading. Empty matches = nothing close enough (not an error). - **Watch**: `client-workflow watch-upload --watch-dir DIR --mode flat|by-subfolder [--tag-pattern "immich-admin-cli/watch/{yyyy-MM-dd}"] [--album-id|--album-name] --interval 60s --stable-for 10s [--once] [--dry-run] [--yes]` polls and uploads only stable files (bulk-check first; duplicates only linked; `--once` forces `--stable-for 0s`, i.e. uploads immediately). A finished folder that nothing is still writing into needs just `--once`; run the loop (no `--once`) only while files are still arriving. diff --git a/internal/config/config.go b/internal/config/config.go index 768ba30..b9f4b76 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,6 +18,20 @@ type Config struct { // Tools holds paths to optional external binaries used by client // workflows for local processing (e.g. ImageMagick for --resize). Tools Tools + // ClipProbe holds the connection settings for the external + // immich-clip-probe service used by `client-workflow find-similar`. + // Optional globally — only find-similar requires it. + ClipProbe ClipProbe +} + +// ClipProbe holds the connection settings for the external visual-similarity +// service (https://github.com/dhcgn/immich-clip-probe/), which runs next to +// Immich. Server is the service origin (e.g. https://clip-probe.example.com/); +// the client appends /v1/similar. Token is the service's own API_TOKEN, not +// an Immich API key. +type ClipProbe struct { + Server string + Token string } // Tools holds explicit paths to external executables used by client @@ -35,8 +49,9 @@ type Tools struct { } // Load reads the YAML config file at path. The environment variables -// IMMICH_SERVER, IMMICH_API_KEY, IMMICH_IMAGEMAGICK_PATH, and -// IMMICH_FFMPEG_PATH override the file values. +// IMMICH_SERVER, IMMICH_API_KEY, IMMICH_IMAGEMAGICK_PATH, +// IMMICH_FFMPEG_PATH, IMMICH_CLIP_PROBE_SERVER, and IMMICH_CLIP_PROBE_TOKEN +// override the file values. func Load(path string) (*Config, error) { v := viper.New() v.SetConfigFile(path) @@ -52,6 +67,12 @@ func Load(path string) (*Config, error) { if err := v.BindEnv("tools.ffmpeg_path", "IMMICH_FFMPEG_PATH"); err != nil { return nil, fmt.Errorf("binding IMMICH_FFMPEG_PATH: %w", err) } + if err := v.BindEnv("clip_probe.server", "IMMICH_CLIP_PROBE_SERVER"); err != nil { + return nil, fmt.Errorf("binding IMMICH_CLIP_PROBE_SERVER: %w", err) + } + if err := v.BindEnv("clip_probe.token", "IMMICH_CLIP_PROBE_TOKEN"); err != nil { + return nil, fmt.Errorf("binding IMMICH_CLIP_PROBE_TOKEN: %w", err) + } if err := v.ReadInConfig(); err != nil { return nil, fmt.Errorf("reading config file %q: %w", path, err) } @@ -63,6 +84,10 @@ func Load(path string) (*Config, error) { ImageMagickPath: v.GetString("tools.imagemagick_path"), FFmpegPath: v.GetString("tools.ffmpeg_path"), }, + ClipProbe: ClipProbe{ + Server: v.GetString("clip_probe.server"), + Token: v.GetString("clip_probe.token"), + }, } if err := cfg.validate(); err != nil { return nil, fmt.Errorf("invalid config %q: %w", path, err) @@ -86,3 +111,25 @@ func (c *Config) validate() error { } return nil } + +// ClipProbeRepoURL is the setup reference for the external visual-similarity +// service. This is the first command that depends on a third-party service +// running next to Immich, so the URL is surfaced in help and errors. +const ClipProbeRepoURL = "https://github.com/dhcgn/immich-clip-probe/" + +// ValidateClipProbe reports whether the clip-probe settings are usable by +// `client-workflow find-similar`. It is opt-in (not part of validate) so +// existing configs without a clip_probe section keep working. +func (c *Config) ValidateClipProbe() error { + if c.ClipProbe.Server == "" || c.ClipProbe.Token == "" { + return fmt.Errorf("clip-probe not configured: set clip_probe.server + clip_probe.token in config (or IMMICH_CLIP_PROBE_SERVER / IMMICH_CLIP_PROBE_TOKEN env). Setup: %s", ClipProbeRepoURL) + } + u, err := url.Parse(c.ClipProbe.Server) + if err != nil { + return fmt.Errorf("clip_probe.server is not a valid URL: %w", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("clip_probe.server must be an http(s) URL, got %q. Setup: %s", c.ClipProbe.Server, ClipProbeRepoURL) + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..0760a5c --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,57 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeConfig(t *testing.T, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +func TestLoadWithoutClipProbe(t *testing.T) { + p := writeConfig(t, "server: https://immich.example.com/\napi_key: k\n") + cfg, err := Load(p) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.ClipProbe.Server != "" || cfg.ClipProbe.Token != "" { + t.Errorf("expected empty clip_probe, got %+v", cfg.ClipProbe) + } + if err := cfg.ValidateClipProbe(); err == nil { + t.Error("expected ValidateClipProbe to fail without clip_probe") + } else if !strings.Contains(err.Error(), ClipProbeRepoURL) { + t.Errorf("missing-config error must link the repo, got: %v", err) + } +} + +func TestLoadWithClipProbeAndEnvOverride(t *testing.T) { + p := writeConfig(t, "server: https://immich.example.com/\napi_key: k\nclip_probe:\n server: https://clip-probe.example.com/\n token: file-token\n") + t.Setenv("IMMICH_CLIP_PROBE_TOKEN", "env-token") + cfg, err := Load(p) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.ClipProbe.Server != "https://clip-probe.example.com/" || cfg.ClipProbe.Token != "env-token" { + t.Errorf("unexpected clip_probe: %+v", cfg.ClipProbe) + } + if err := cfg.ValidateClipProbe(); err != nil { + t.Errorf("ValidateClipProbe: %v", err) + } +} + +func TestValidateClipProbeBadURL(t *testing.T) { + cfg := &Config{Server: "https://immich.example.com/", APIKey: "k", ClipProbe: ClipProbe{Server: "ftp://x", Token: "t"}} + if err := cfg.ValidateClipProbe(); err == nil { + t.Error("expected error for non-http(s) clip_probe.server") + } else if !strings.Contains(err.Error(), ClipProbeRepoURL) { + t.Errorf("bad-URL error must link the repo, got: %v", err) + } +} From bbd05060328d6295342e3cc1aadb95371c4fb3bf Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 19 Sep 2026 20:51:13 +0000 Subject: [PATCH 3/4] fix: pretty-print find-similar --json output --- internal/commands/clientworkflow_findsimilar.go | 15 ++++++++++++++- .../commands/clientworkflow_findsimilar_test.go | 17 +++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 internal/commands/clientworkflow_findsimilar_test.go diff --git a/internal/commands/clientworkflow_findsimilar.go b/internal/commands/clientworkflow_findsimilar.go index 651ba25..c2e4e1c 100644 --- a/internal/commands/clientworkflow_findsimilar.go +++ b/internal/commands/clientworkflow_findsimilar.go @@ -1,7 +1,9 @@ package commands import ( + "bytes" "context" + "encoding/json" "fmt" "os" @@ -101,7 +103,7 @@ func clientWorkflowFindSimilar(ctx context.Context, cmd *cli.Command) error { switch { case cmd.Bool("json"): - fmt.Println(string(raw)) + fmt.Println(formatJSON(raw)) case cmd.Bool("ids-only"): for _, m := range resp.Matches { fmt.Println(m.AssetID) @@ -120,3 +122,14 @@ func clientWorkflowFindSimilar(ctx context.Context, cmd *cli.Command) error { } return nil } + +// formatJSON pretty-prints a raw JSON response for --json output. Unparseable +// input falls back to the raw string (never fails a read-only command on a +// formatting issue). Pure so it is directly unit-testable. +func formatJSON(raw []byte) string { + var out bytes.Buffer + if err := json.Indent(&out, raw, "", " "); err != nil { + return string(raw) + } + return out.String() +} diff --git a/internal/commands/clientworkflow_findsimilar_test.go b/internal/commands/clientworkflow_findsimilar_test.go new file mode 100644 index 0000000..8e4b33b --- /dev/null +++ b/internal/commands/clientworkflow_findsimilar_test.go @@ -0,0 +1,17 @@ +package commands + +import ( + "strings" + "testing" +) + +func TestFormatJSON(t *testing.T) { + got := formatJSON([]byte(`{"duplicate":true,"matches":[{"assetId":"x"}]}`)) + if !strings.Contains(got, "\n") || !strings.Contains(got, ` "duplicate": true`) { + t.Errorf("expected indented JSON, got:\n%s", got) + } + // Fallback: garbage in, same garbage out (no error, no empty output). + if got := formatJSON([]byte(`not json`)); got != `not json` { + t.Errorf("expected raw fallback, got %q", got) + } +} From bb7d2c01ce7529855bf7603d1795d799a809a651 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 19 Sep 2026 20:54:08 +0000 Subject: [PATCH 4/4] fix: show help on find-similar missing FILE arg, like required-flag errors --- internal/commands/clientworkflow_findsimilar.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/internal/commands/clientworkflow_findsimilar.go b/internal/commands/clientworkflow_findsimilar.go index c2e4e1c..6eec930 100644 --- a/internal/commands/clientworkflow_findsimilar.go +++ b/internal/commands/clientworkflow_findsimilar.go @@ -61,7 +61,7 @@ func findSimilarCommand() *cli.Command { func clientWorkflowFindSimilar(ctx context.Context, cmd *cli.Command) error { args := cmd.Args().Slice() if len(args) != 1 { - return fmt.Errorf("expected exactly 1 positional argument (FILE), got %d", len(args)) + return incorrectUsage(cmd, "expected exactly 1 positional argument (FILE), got %d", len(args)) } file := args[0] if st, err := os.Stat(file); err != nil { @@ -123,6 +123,19 @@ func clientWorkflowFindSimilar(ctx context.Context, cmd *cli.Command) error { return nil } +// incorrectUsage mimics the framework's own missing-required-flag output +// ("Incorrect Usage: ..." plus the command help on stderr) for validation +// failures the framework never sees — i.e. positional-arg checks inside the +// Action, which run after flag parsing. It returns the error so the caller +// can `return` it directly; main then prints the trailing "Error: ..." line, +// exactly like a required-flag violation. +func incorrectUsage(cmd *cli.Command, format string, args ...any) error { + err := fmt.Errorf(format, args...) + fmt.Fprintf(cmd.Root().ErrWriter, "Incorrect Usage: %s\n\n", err.Error()) + _ = cli.ShowSubcommandHelp(cmd) + return err +} + // formatJSON pretty-prints a raw JSON response for --json output. Unparseable // input falls back to the raw string (never fails a read-only command on a // formatting issue). Pure so it is directly unit-testable.