From d9281bcde955eecfda8fc1eab1235e4e3ab70725 Mon Sep 17 00:00:00 2001 From: Russ Long Date: Mon, 1 Jun 2026 12:21:02 -0400 Subject: [PATCH] feat: implement faces api with resource and data source --- docs/data-sources/faces.md | 48 ++++ docs/resources/face.md | 44 ++++ .../data-sources/immich_faces/data-source.tf | 7 + examples/resources/immich_face/resource.tf | 10 + internal/client/face.go | 123 ++++++++++ internal/provider/face_resource.go | 231 ++++++++++++++++++ internal/provider/faces_data_source.go | 149 +++++++++++ internal/provider/provider.go | 2 + 8 files changed, 614 insertions(+) create mode 100644 docs/data-sources/faces.md create mode 100644 docs/resources/face.md create mode 100644 examples/data-sources/immich_faces/data-source.tf create mode 100644 examples/resources/immich_face/resource.tf create mode 100644 internal/client/face.go create mode 100644 internal/provider/face_resource.go create mode 100644 internal/provider/faces_data_source.go diff --git a/docs/data-sources/faces.md b/docs/data-sources/faces.md new file mode 100644 index 0000000..e894668 --- /dev/null +++ b/docs/data-sources/faces.md @@ -0,0 +1,48 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "immich_faces Data Source - immich" +subcategory: "" +description: |- + Retrieves a list of faces for a specific asset. +--- + +# immich_faces (Data Source) + +Retrieves a list of faces for a specific asset. + +## Example Usage + +```terraform +data "immich_faces" "current" { + asset_id = "your-asset-uuid" +} + +output "detected_faces" { + value = data.immich_faces.current.faces +} +``` + + +## Schema + +### Required + +- `asset_id` (String) The ID of the asset. + +### Read-Only + +- `faces` (Attributes List) List of faces. (see [below for nested schema](#nestedatt--faces)) + + +### Nested Schema for `faces` + +Read-Only: + +- `bounding_box_x1` (Number) The left coordinate of the bounding box. +- `bounding_box_x2` (Number) The right coordinate of the bounding box. +- `bounding_box_y1` (Number) The top coordinate of the bounding box. +- `bounding_box_y2` (Number) The bottom coordinate of the bounding box. +- `id` (String) Unique identifier for the face. +- `image_height` (Number) The height of the image in pixels. +- `image_width` (Number) The width of the image in pixels. +- `person_id` (String) The ID of the person associated with this face. diff --git a/docs/resources/face.md b/docs/resources/face.md new file mode 100644 index 0000000..9155642 --- /dev/null +++ b/docs/resources/face.md @@ -0,0 +1,44 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "immich_face Resource - immich" +subcategory: "" +description: |- + Manages an Immich face (detected or manual). +--- + +# immich_face (Resource) + +Manages an Immich face (detected or manual). + +## Example Usage + +```terraform +resource "immich_face" "manual" { + asset_id = "your-asset-uuid" + person_id = "your-person-uuid" + bounding_box_x1 = 100.5 + bounding_box_y1 = 100.5 + bounding_box_x2 = 200.5 + bounding_box_y2 = 200.5 + image_height = 1080 + image_width = 1920 +} +``` + + +## Schema + +### Required + +- `asset_id` (String) The ID of the asset this face belongs to. +- `bounding_box_x1` (Number) The left coordinate of the bounding box. +- `bounding_box_x2` (Number) The right coordinate of the bounding box. +- `bounding_box_y1` (Number) The top coordinate of the bounding box. +- `bounding_box_y2` (Number) The bottom coordinate of the bounding box. +- `image_height` (Number) The height of the image in pixels. +- `image_width` (Number) The width of the image in pixels. +- `person_id` (String) The ID of the person associated with this face. + +### Read-Only + +- `id` (String) Unique identifier for the face. diff --git a/examples/data-sources/immich_faces/data-source.tf b/examples/data-sources/immich_faces/data-source.tf new file mode 100644 index 0000000..ecf7ca8 --- /dev/null +++ b/examples/data-sources/immich_faces/data-source.tf @@ -0,0 +1,7 @@ +data "immich_faces" "current" { + asset_id = "your-asset-uuid" +} + +output "detected_faces" { + value = data.immich_faces.current.faces +} diff --git a/examples/resources/immich_face/resource.tf b/examples/resources/immich_face/resource.tf new file mode 100644 index 0000000..6459185 --- /dev/null +++ b/examples/resources/immich_face/resource.tf @@ -0,0 +1,10 @@ +resource "immich_face" "manual" { + asset_id = "your-asset-uuid" + person_id = "your-person-uuid" + bounding_box_x1 = 100.5 + bounding_box_y1 = 100.5 + bounding_box_x2 = 200.5 + bounding_box_y2 = 200.5 + image_height = 1080 + image_width = 1920 +} diff --git a/internal/client/face.go b/internal/client/face.go new file mode 100644 index 0000000..562753c --- /dev/null +++ b/internal/client/face.go @@ -0,0 +1,123 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" +) + +type Face struct { + ID string `json:"id"` + AssetId string `json:"assetId"` + PersonId string `json:"personId"` + BoundingBoxX1 float64 `json:"boundingBoxX1"` + BoundingBoxY1 float64 `json:"boundingBoxY1"` + BoundingBoxX2 float64 `json:"boundingBoxX2"` + BoundingBoxY2 float64 `json:"boundingBoxY2"` + ImageHeight int `json:"imageHeight"` + ImageWidth int `json:"imageWidth"` +} + +type CreateFaceRequest struct { + AssetId string `json:"assetId"` + PersonId string `json:"personId"` + BoundingBoxX1 float64 `json:"boundingBoxX1"` + BoundingBoxY1 float64 `json:"boundingBoxY1"` + BoundingBoxX2 float64 `json:"boundingBoxX2"` + BoundingBoxY2 float64 `json:"boundingBoxY2"` + ImageHeight int `json:"imageHeight"` + ImageWidth int `json:"imageWidth"` +} + +type UpdateFaceRequest struct { + PersonId string `json:"personId"` +} + +func (c *Client) GetFaces(assetId string) ([]Face, error) { + url := fmt.Sprintf("%s/faces?id=%s", c.HostURL, assetId) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + body, err := c.doRequest(req) + if err != nil { + return nil, err + } + + var faces []Face + err = json.Unmarshal(body, &faces) + if err != nil { + return nil, err + } + + return faces, nil +} + +func (c *Client) CreateFace(face CreateFaceRequest) (*Face, error) { + rb, err := json.Marshal(face) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", fmt.Sprintf("%s/faces", c.HostURL), bytes.NewBuffer(rb)) + if err != nil { + return nil, err + } + + body, err := c.doRequest(req) + if err != nil { + return nil, err + } + + var newFace Face + err = json.Unmarshal(body, &newFace) + if err != nil { + return nil, err + } + + return &newFace, nil +} + +func (c *Client) UpdateFace(id string, update UpdateFaceRequest) (*Face, error) { + // Bulk reassign endpoint is PUT /faces, but usually for a single face it might be PUT /faces/{id} + // or we use the bulk one with one ID. + // Documentation said PUT /faces with a list of ids and a personId. + type BulkUpdateFaceRequest struct { + Ids []string `json:"ids"` + PersonId string `json:"personId"` + } + + rb, err := json.Marshal(BulkUpdateFaceRequest{ + Ids: []string{id}, + PersonId: update.PersonId, + }) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("PUT", fmt.Sprintf("%s/faces", c.HostURL), bytes.NewBuffer(rb)) + if err != nil { + return nil, err + } + + _, err = c.doRequest(req) + if err != nil { + return nil, err + } + + // The API might not return the updated face, so we might need to fetch it or just return a placeholder. + // But wait, GET /faces requires assetId. If we don't have it, we can't easily fetch it back by ID alone if GET /faces/{id} doesn't exist. + return &Face{ID: id, PersonId: update.PersonId}, nil +} + +func (c *Client) DeleteFace(id string) error { + req, err := http.NewRequest("DELETE", fmt.Sprintf("%s/faces/%s", c.HostURL, id), bytes.NewBufferString(`{"force": true}`)) + if err != nil { + return err + } + + _, err = c.doRequest(req) + return err +} diff --git a/internal/provider/face_resource.go b/internal/provider/face_resource.go new file mode 100644 index 0000000..98d3a86 --- /dev/null +++ b/internal/provider/face_resource.go @@ -0,0 +1,231 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/tfmm/terraform-provider-immich/internal/client" +) + +// Ensure the implementation satisfies the expected interfaces. +var _ resource.Resource = &faceResource{} +var _ resource.ResourceWithImportState = &faceResource{} + +func NewFaceResource() resource.Resource { + return &faceResource{} +} + +// faceResource defines the resource implementation. +type faceResource struct { + client *client.Client +} + +// faceResourceModel describes the resource data model. +type faceResourceModel struct { + ID types.String `tfsdk:"id"` + AssetId types.String `tfsdk:"asset_id"` + PersonId types.String `tfsdk:"person_id"` + BoundingBoxX1 types.Float64 `tfsdk:"bounding_box_x1"` + BoundingBoxY1 types.Float64 `tfsdk:"bounding_box_y1"` + BoundingBoxX2 types.Float64 `tfsdk:"bounding_box_x2"` + BoundingBoxY2 types.Float64 `tfsdk:"bounding_box_y2"` + ImageHeight types.Int64 `tfsdk:"image_height"` + ImageWidth types.Int64 `tfsdk:"image_width"` +} + +func (r *faceResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_face" +} + +func (r *faceResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Manages an Immich face (detected or manual).", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Unique identifier for the face.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "asset_id": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The ID of the asset this face belongs to.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "person_id": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The ID of the person associated with this face.", + }, + "bounding_box_x1": schema.Float64Attribute{ + Required: true, + MarkdownDescription: "The left coordinate of the bounding box.", + PlanModifiers: []planmodifier.Float64{ + // RequiresReplace because we can't update bounding box via PUT /faces + }, + }, + "bounding_box_y1": schema.Float64Attribute{ + Required: true, + MarkdownDescription: "The top coordinate of the bounding box.", + }, + "bounding_box_x2": schema.Float64Attribute{ + Required: true, + MarkdownDescription: "The right coordinate of the bounding box.", + }, + "bounding_box_y2": schema.Float64Attribute{ + Required: true, + MarkdownDescription: "The bottom coordinate of the bounding box.", + }, + "image_height": schema.Int64Attribute{ + Required: true, + MarkdownDescription: "The height of the image in pixels.", + }, + "image_width": schema.Int64Attribute{ + Required: true, + MarkdownDescription: "The width of the image in pixels.", + }, + }, + } +} + +func (r *faceResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*client.Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.client = client +} + +func (r *faceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data faceResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + createReq := client.CreateFaceRequest{ + AssetId: data.AssetId.ValueString(), + PersonId: data.PersonId.ValueString(), + BoundingBoxX1: data.BoundingBoxX1.ValueFloat64(), + BoundingBoxY1: data.BoundingBoxY1.ValueFloat64(), + BoundingBoxX2: data.BoundingBoxX2.ValueFloat64(), + BoundingBoxY2: data.BoundingBoxY2.ValueFloat64(), + ImageHeight: int(data.ImageHeight.ValueInt64()), + ImageWidth: int(data.ImageWidth.ValueInt64()), + } + + face, err := r.client.CreateFace(createReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create face, got error: %s", err)) + return + } + + data.ID = types.StringValue(face.ID) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *faceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data faceResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + // We have to find the face in the list for the asset + faces, err := r.client.GetFaces(data.AssetId.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read faces for asset, got error: %s", err)) + return + } + + var found *client.Face + for _, f := range faces { + if f.ID == data.ID.ValueString() { + found = &f + break + } + } + + if found == nil { + resp.State.RemoveResource(ctx) + return + } + + data.PersonId = types.StringValue(found.PersonId) + data.BoundingBoxX1 = types.Float64Value(found.BoundingBoxX1) + data.BoundingBoxY1 = types.Float64Value(found.BoundingBoxY1) + data.BoundingBoxX2 = types.Float64Value(found.BoundingBoxX2) + data.BoundingBoxY2 = types.Float64Value(found.BoundingBoxY2) + data.ImageHeight = types.Int64Value(int64(found.ImageHeight)) + data.ImageWidth = types.Int64Value(int64(found.ImageWidth)) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *faceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data faceResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + updateReq := client.UpdateFaceRequest{ + PersonId: data.PersonId.ValueString(), + } + + _, err := r.client.UpdateFace(data.ID.ValueString(), updateReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update face, got error: %s", err)) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *faceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data faceResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + err := r.client.DeleteFace(data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete face, got error: %s", err)) + return + } +} + +func (r *faceResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + // Expected format: face_id + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} diff --git a/internal/provider/faces_data_source.go b/internal/provider/faces_data_source.go new file mode 100644 index 0000000..9335a0d --- /dev/null +++ b/internal/provider/faces_data_source.go @@ -0,0 +1,149 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/tfmm/terraform-provider-immich/internal/client" +) + +// Ensure the implementation satisfies the expected interfaces. +var _ datasource.DataSource = &facesDataSource{} + +func NewFacesDataSource() datasource.DataSource { + return &facesDataSource{} +} + +// facesDataSource defines the data source implementation. +type facesDataSource struct { + client *client.Client +} + +// facesDataSourceModel describes the data source data model. +type facesDataSourceModel struct { + AssetId types.String `tfsdk:"asset_id"` + Faces []facesModel `tfsdk:"faces"` +} + +type facesModel struct { + ID types.String `tfsdk:"id"` + PersonId types.String `tfsdk:"person_id"` + BoundingBoxX1 types.Float64 `tfsdk:"bounding_box_x1"` + BoundingBoxY1 types.Float64 `tfsdk:"bounding_box_y1"` + BoundingBoxX2 types.Float64 `tfsdk:"bounding_box_x2"` + BoundingBoxY2 types.Float64 `tfsdk:"bounding_box_y2"` + ImageHeight types.Int64 `tfsdk:"image_height"` + ImageWidth types.Int64 `tfsdk:"image_width"` +} + +func (d *facesDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_faces" +} + +func (d *facesDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Retrieves a list of faces for a specific asset.", + + Attributes: map[string]schema.Attribute{ + "asset_id": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The ID of the asset.", + }, + "faces": schema.ListNestedAttribute{ + Computed: true, + MarkdownDescription: "List of faces.", + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Unique identifier for the face.", + }, + "person_id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The ID of the person associated with this face.", + }, + "bounding_box_x1": schema.Float64Attribute{ + Computed: true, + MarkdownDescription: "The left coordinate of the bounding box.", + }, + "bounding_box_y1": schema.Float64Attribute{ + Computed: true, + MarkdownDescription: "The top coordinate of the bounding box.", + }, + "bounding_box_x2": schema.Float64Attribute{ + Computed: true, + MarkdownDescription: "The right coordinate of the bounding box.", + }, + "bounding_box_y2": schema.Float64Attribute{ + Computed: true, + MarkdownDescription: "The bottom coordinate of the bounding box.", + }, + "image_height": schema.Int64Attribute{ + Computed: true, + MarkdownDescription: "The height of the image in pixels.", + }, + "image_width": schema.Int64Attribute{ + Computed: true, + MarkdownDescription: "The width of the image in pixels.", + }, + }, + }, + }, + }, + } +} + +func (d *facesDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*client.Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *client.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + d.client = client +} + +func (d *facesDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data facesDataSourceModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + faces, err := d.client.GetFaces(data.AssetId.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read faces, got error: %s", err)) + return + } + + data.Faces = []facesModel{} + for _, f := range faces { + fState := facesModel{ + ID: types.StringValue(f.ID), + PersonId: types.StringValue(f.PersonId), + BoundingBoxX1: types.Float64Value(f.BoundingBoxX1), + BoundingBoxY1: types.Float64Value(f.BoundingBoxY1), + BoundingBoxX2: types.Float64Value(f.BoundingBoxX2), + BoundingBoxY2: types.Float64Value(f.BoundingBoxY2), + ImageHeight: types.Int64Value(int64(f.ImageHeight)), + ImageWidth: types.Int64Value(int64(f.ImageWidth)), + } + data.Faces = append(data.Faces, fState) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 2ee2611..e7609e3 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -100,6 +100,7 @@ func (p *immichProvider) Resources(ctx context.Context) []func() resource.Resour NewTagResource, NewWorkflowResource, NewAdminNotificationResource, + NewFaceResource, } } @@ -111,6 +112,7 @@ func (p *immichProvider) DataSources(ctx context.Context) []func() datasource.Da NewActivitiesDataSource, NewServerDataSource, NewNotificationsDataSource, + NewFacesDataSource, } }