Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -601,7 +601,7 @@ This downloads every asset in the "Rome 2026" album into `Nextcloud\Shares\Rome

<!-- Generated by tools/apitable — do not edit between the markers. Refresh with `go generate ./...` -->
<!-- API-TABLE:BEGIN -->
**27 of 250 endpoints implemented** (24 deprecated and 2 internal endpoints omitted per project policy).
**28 of 250 endpoints implemented** (24 deprecated and 2 internal endpoints omitted per project policy).

<details>
<summary><b>API keys</b> (0/6)</summary>
Expand Down Expand Up @@ -663,13 +663,13 @@ This downloads every asset in the "Rome 2026" album into `Nextcloud\Shares\Rome
</details>

<details>
<summary><b>Assets</b> (6/24)</summary>
<summary><b>Assets</b> (7/24)</summary>

| Impl | Method | Path | Operation | State |
|:----:|--------|------|-----------|-------|
| ✅ | DELETE | `/assets` | `deleteAssets` | Stable |
| ✅ | POST | `/assets` | `uploadAsset` | Stable |
| | POST | `/assets/bulk-upload-check` | `checkBulkUpload` | Stable |
| ✅ | POST | `/assets/bulk-upload-check` | `checkBulkUpload` | Stable |
| ✅ | PUT | `/assets/copy` | `copyAsset` | Stable |
| | POST | `/assets/jobs` | `runAssetJobs` | Stable |
| | DELETE | `/assets/metadata` | `deleteBulkAssetMetadata` | Beta |
Expand Down
55 changes: 54 additions & 1 deletion immich-admin-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Download original asset files (GET /assets/{id}/original)
Args: `[ASSET_ID ...]`
- `--ids-file` (string): read IDs from `FILE`, one UUID per line ('-' for stdin; '#' or '//' starts a comment)
- `--out-dir` (string): directory to save downloaded files into (default: current working directory)
- `--quiet` (bool): disable per-file progress bars on stderr

### assets download-thumbnail

Expand All @@ -50,6 +51,7 @@ Args: `[ASSET_ID ...]`
- `--out-dir` (string): directory to save downloaded files into (default: current working directory)
- `--size` (string): media size: fullsize, preview, or thumbnail (see the AssetMediaSize spec enum; 'original' is not accepted here — the spec deprecates size=original on this endpoint, use 'assets download-original' instead) [default: "preview"]
- `--edited` (bool): return the edited version of the asset if available
- `--quiet` (bool): disable per-file progress bars on stderr

### assets upload

Expand All @@ -67,6 +69,16 @@ Args: `[FILE ...]`
- `--slug` (string): shared-link slug (query param)
- `--checksum` (string): sha1 checksum for duplicate detection before upload (x-immich-checksum header)
- `--json` (bool): print results as a JSON array
- `--quiet` (bool): disable per-file progress bars on stderr (--json implies quiet)

### assets check-remote-exists (alias: check-bulk-upload)

Check if local files already exist on the server via checksum (POST /assets/bulk-upload-check)
Args: `[FILE|DIR ...]`
- `--json` (bool): print results as a JSON array
- `--duplicates-only` (bool): show only files already on the server
- `--missing-only` (bool): show only files missing on the server
- `--ids-only, -q` (bool): print only duplicate asset IDs, one per line (pipeable into albums add-assets / tags tag)

### assets delete

Expand Down Expand Up @@ -355,6 +367,7 @@ Download all original files or a smaller variant (preview/thumbnail/fullsize) fr
- `--resize-video-preset` (string): re-encode every downloaded VIDEO asset using ffmpeg (path from config tools.ffmpeg_path or IMMICH_FFMPEG_PATH, falling back to PATH); videos are always fetched at --size original for this regardless of --size (many videos have no usable preview/thumbnail rendition, and ffmpeg needs the real stream anyway) — only non-video assets use --size; valid presets: 1080p-web-friendly
- `--dry-run` (bool): print the planned downloads/deletions without changing anything
- `--yes` (bool): skip the confirmation prompt before deleting local files (--sync only)
- `--quiet` (bool): disable per-file progress bars on stderr

### client-workflow merge-album

Expand All @@ -365,6 +378,39 @@ 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 watch-upload

Watch a folder and auto-upload new stable files (POST /assets/bulk-upload-check + POST /assets)
- `--watch-dir` (string): local directory to watch [required]
- `--mode` (string): flat or by-subfolder [default: "flat"]
- `--tag-pattern` (string): tag pattern for --mode flat ({yyyy-MM-dd} = upload day) [default: "immich-admin-cli/watch/{yyyy-MM-dd}"]
- `--depth` (int): subfolder depth (v1: only 1 supported) [default: 1]
- `--album-id` (string): opt-in album `ID` to also add uploads to
- `--album-name` (string): opt-in album name to also add uploads to (created unless --dry-run)
- `--interval` (string): poll interval (e.g. 60s); <=0 means run once [default: "60s"]
- `--stable-for` (string): defer files changed within this long (default 10s; forced to 0s with --once) [default: "10s"]
- `--once` (bool): run a single scan and exit (for cron); uploads immediately (--stable-for is forced to 0s)
- `--dry-run` (bool): print what would be uploaded/linked without changing anything
- `--yes` (bool): skip creation prompts for tags/albums
- `--quiet` (bool): disable per-file progress bars on stderr (--json implies quiet)
- `--json` (bool): print per-interval stats as JSON on stdout

### client-workflow watch-download

Keep a local folder in sync from an album or tag (loop around download-album --sync)
- `--album-id` (string): album `ID` source (mutually exclusive with --album-name/--tag-*)
- `--album-name` (string): album name source
- `--tag-id` (string): tag `ID` source (mutually exclusive with --tag-value/album-*)
- `--tag-value` (string): tag value (full path) source
- `--target-dir` (string): local directory to sync into (created if missing) [required]
- `--size` (string): media variant: original, fullsize, preview, or thumbnail [default: "original"]
- `--interval` (string): poll interval (e.g. 300s); <=0 means run once [default: "300s"]
- `--once` (bool): run a single sync and exit (for cron)
- `--dry-run` (bool): print the planned sync without changing anything
- `--yes` (bool): skip the deletion confirmation prompt
- `--quiet` (bool): disable per-file progress bars on stderr (--json implies quiet)
- `--json` (bool): print per-interval stats as JSON on stdout

## immich-workflow

Server-side workflow operations
Expand Down Expand Up @@ -520,4 +566,11 @@ albums separate, or rename instead of merging blindly. Always run
`client-workflow tag-delete --include/--exclude` for regex bulk deletion
(permanent — dry-run first).
- **Upload**: `assets upload FILE...` (timestamps default to file mtime;
`--sidecar`/`--filename` are single-file only).
`--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 <id>` / `missing` / `unsupported` without mutating (`--json`, `--ids-only -q` pipes duplicate IDs into `albums add-assets`/`tags tag`).
- **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.
`client-workflow watch-download (--album-id|--album-name|--tag-id|--tag-value) --target-dir DIR --size original --interval 300s [--once]`
loops the `.immich-sync.json` manifest sync (album or tag source). `--interval <=0` means run once. Per-interval one-line summary on stderr; `--json` prints JSON stats on stdout.
1 change: 1 addition & 0 deletions internal/commands/assets.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ func Assets() *cli.Command {
assetsDownloadCommand(),
assetsDownloadThumbnailCommand(),
assetsUploadCommand(),
assetsCheckRemoteExistsCommand(),
assetsDeleteCommand(),
assetsCopyCommand(),
},
Expand Down
134 changes: 134 additions & 0 deletions internal/commands/assets_check.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package commands

import (
"context"
"encoding/json"
"fmt"
"os"

"github.com/urfave/cli/v3"

"github.com/dhcgn/immich-admin-cli/internal/workflows"
)

// assetsCheckRemoteExistsCommand exposes POST /assets/bulk-upload-check
// (checkBulkUpload) as `assets check-remote-exists` (alias check-bulk-upload).
func assetsCheckRemoteExistsCommand() *cli.Command {
return &cli.Command{
Name: "check-remote-exists",
Aliases: []string{"check-bulk-upload"},
Usage: "Check if local files already exist on the server via checksum (POST /assets/bulk-upload-check)",
Description: "Hashes each local file (base64 SHA1, the format Immich reports " +
"as Checksum) and asks the server which checksums it already has. " +
"Read-only: never uploads, downloads, or mutates anything.",
ArgsUsage: "[FILE|DIR ...]",
Flags: []cli.Flag{
&cli.BoolFlag{Name: "json", Usage: "print results as a JSON array"},
&cli.BoolFlag{Name: "duplicates-only", Usage: "show only files already on the server"},
&cli.BoolFlag{Name: "missing-only", Usage: "show only files missing on the server"},
&cli.BoolFlag{Name: "ids-only", Aliases: []string{"q"}, Usage: "print only duplicate asset IDs, one per line (pipeable into albums add-assets / tags tag)"},
},
Action: assetsCheckRemoteExists,
}
}

type checkRemoteResult struct {
File string `json:"file"`
Checksum string `json:"checksum"`
Status string `json:"status"`
AssetID string `json:"assetId,omitempty"`
Trashed bool `json:"isTrashed,omitempty"`
}

func assetsCheckRemoteExists(ctx context.Context, cmd *cli.Command) error {
paths := cmd.Args().Slice()
if len(paths) == 0 {
return fmt.Errorf("no files given: pass [FILE|DIR ...]")
}
files, err := workflows.CollectCheckFiles(paths)
if err != nil {
return err
}
if len(files) == 0 {
return fmt.Errorf("no files found in %v (hidden files/symlinks are skipped)", paths)
}

c, err := newClient(ctx, cmd)
if err != nil {
return err
}

// Hash locally first so one unreadable file doesn't abort the server check.
checksums := make(map[string]string, len(files))
var hashable []string
failures := 0
for i, f := range files {
fmt.Fprintf(os.Stderr, "[%d/%d] hashing %s\n", i+1, len(files), f)
sum, herr := workflows.FileSHA1Base64(f)
if herr != nil {
fmt.Fprintf(os.Stderr, "Error: file %s: %v\n", f, herr)
failures++
continue
}
checksums[f] = sum
hashable = append(hashable, f)
}

entries, err := workflows.CheckBulkUploadChecksums(ctx, c, hashable, checksums)
if err != nil {
return err
}

dupsOnly := cmd.Bool("duplicates-only")
missOnly := cmd.Bool("missing-only")
idsOnly := cmd.Bool("ids-only")

var results []checkRemoteResult
for _, e := range entries {
switch {
case idsOnly && e.Status != workflows.BulkCheckUploaded:
continue
case dupsOnly && e.Status != workflows.BulkCheckUploaded:
continue
case missOnly && e.Status != workflows.BulkCheckMissing:
continue
}
r := checkRemoteResult{File: e.File, Checksum: e.Checksum, Status: string(e.Status)}
if e.AssetID != nil {
r.AssetID = e.AssetID.String()
}
r.Trashed = e.IsTrashed
results = append(results, r)
}

if idsOnly {
for _, r := range results {
fmt.Println(r.AssetID)
}
} else if cmd.Bool("json") {
if results == nil {
results = []checkRemoteResult{}
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(results); err != nil {
return err
}
} else {
for _, r := range results {
switch r.Status {
case string(workflows.BulkCheckUploaded):
fmt.Printf("%s: uploaded %s\n", r.File, r.AssetID)
case string(workflows.BulkCheckMissing):
fmt.Printf("%s: missing\n", r.File)
default:
fmt.Printf("%s: unsupported\n", r.File)
}
}
}

if failures > 0 {
return fmt.Errorf("%d of %d files failed", failures, len(files))
}
return nil
}
52 changes: 44 additions & 8 deletions internal/commands/assets_download.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ func assetsDownloadCommand() *cli.Command {
Name: "out-dir",
Usage: "directory to save downloaded files into (default: current working directory)",
},
&cli.BoolFlag{Name: "quiet", Usage: "disable per-file progress bars on stderr"},
},
Action: assetsDownloadOriginal,
}
Expand All @@ -56,8 +57,10 @@ func assetsDownloadOriginal(ctx context.Context, cmd *cli.Command) error {
// Fan out over the single-asset download endpoint: continue on per-ID
// errors and report failures at the end (same convention as assetsInfo).
failures := 0
for _, id := range ids {
if err := downloadOneAsset(ctx, c, id, outDir); err != nil {
quiet := cmd.Bool("quiet")
for i, id := range ids {
prog := workflows.NewByteProgress(id.String(), -1, i+1, len(ids), quiet)
if err := downloadOneAsset(ctx, c, id, outDir, prog); err != nil {
fmt.Fprintf(os.Stderr, "Error: asset %s: %v\n", id, err)
failures++
continue
Expand All @@ -70,7 +73,7 @@ func assetsDownloadOriginal(ctx context.Context, cmd *cli.Command) error {
return nil
}

func downloadOneAsset(ctx context.Context, c *client.Client, id openapi_types.UUID, outDir string) error {
func downloadOneAsset(ctx context.Context, c *client.Client, id openapi_types.UUID, outDir string, prog *workflows.ByteProgress) error {
infoResp, err := c.API.GetAssetInfoWithResponse(ctx, id, nil)
if err == nil {
err = client.Check(infoResp, http.StatusOK)
Expand All @@ -97,13 +100,28 @@ func downloadOneAsset(ctx context.Context, c *client.Client, id openapi_types.UU
idStr := id.String()
destPath := filepath.Join(outDir, idStr+"_"+originalFileName)

if prog != nil {
prog.Label = originalFileName
prog.Total = resp.ContentLength
if prog.Total <= 0 {
prog.Total = -1
}
}

f, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("creating output file %q: %w", destPath, err)
}
defer f.Close()

written, err := io.Copy(f, resp.Body)
var src io.Reader = resp.Body
if prog != nil {
src = prog.Wrap(resp.Body)
}
written, err := io.Copy(f, src)
if prog != nil {
prog.Finish()
}
if err != nil {
return fmt.Errorf("writing output file %q: %w", destPath, err)
}
Expand Down Expand Up @@ -149,6 +167,7 @@ func assetsDownloadThumbnailCommand() *cli.Command {
Name: "edited",
Usage: "return the edited version of the asset if available",
},
&cli.BoolFlag{Name: "quiet", Usage: "disable per-file progress bars on stderr"},
},
Action: assetsDownloadThumbnail,
}
Expand Down Expand Up @@ -187,8 +206,10 @@ func assetsDownloadThumbnail(ctx context.Context, cmd *cli.Command) error {
// Fan out over the single-asset endpoint: continue on per-ID errors and
// report failures at the end (same convention as assetsDownloadOriginal).
failures := 0
for _, id := range ids {
if err := downloadOneThumbnail(ctx, c, id, outDir, params); err != nil {
quiet := cmd.Bool("quiet")
for i, id := range ids {
prog := workflows.NewByteProgress(id.String(), -1, i+1, len(ids), quiet)
if err := downloadOneThumbnail(ctx, c, id, outDir, params, prog); err != nil {
fmt.Fprintf(os.Stderr, "Error: asset %s: %v\n", id, err)
failures++
continue
Expand All @@ -201,7 +222,7 @@ func assetsDownloadThumbnail(ctx context.Context, cmd *cli.Command) error {
return nil
}

func downloadOneThumbnail(ctx context.Context, c *client.Client, id openapi_types.UUID, outDir string, params *immichapi.ViewAssetParams) error {
func downloadOneThumbnail(ctx context.Context, c *client.Client, id openapi_types.UUID, outDir string, params *immichapi.ViewAssetParams, prog *workflows.ByteProgress) error {
infoResp, err := c.API.GetAssetInfoWithResponse(ctx, id, nil)
if err == nil {
err = client.Check(infoResp, http.StatusOK)
Expand Down Expand Up @@ -232,13 +253,28 @@ func downloadOneThumbnail(ctx context.Context, c *client.Client, id openapi_type
idStr := id.String()
destPath := filepath.Join(outDir, idStr+"_"+baseName+ext)

if prog != nil {
prog.Label = baseName + ext
prog.Total = resp.ContentLength
if prog.Total <= 0 {
prog.Total = -1
}
}

f, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("creating output file %q: %w", destPath, err)
}
defer f.Close()

written, err := io.Copy(f, resp.Body)
var src io.Reader = resp.Body
if prog != nil {
src = prog.Wrap(resp.Body)
}
written, err := io.Copy(f, src)
if prog != nil {
prog.Finish()
}
if err != nil {
return fmt.Errorf("writing output file %q: %w", destPath, err)
}
Expand Down
Loading
Loading