diff --git a/internal/client/people.go b/internal/client/people.go new file mode 100644 index 0000000..ecb8f13 --- /dev/null +++ b/internal/client/people.go @@ -0,0 +1,111 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" +) + +type Person struct { + ID string `json:"id"` + Name string `json:"name"` + BirthDate string `json:"birthDate,omitempty"` + ThumbnailPath string `json:"thumbnailPath,omitempty"` + IsHidden bool `json:"isHidden"` + IsFavorite bool `json:"isFavorite"` +} + +type UpdatePersonRequest struct { + Name string `json:"name,omitempty"` + BirthDate string `json:"birthDate,omitempty"` + IsHidden *bool `json:"isHidden,omitempty"` + IsFavorite *bool `json:"isFavorite,omitempty"` +} + +func (c *Client) GetPeople(withHidden bool) ([]Person, error) { + url := fmt.Sprintf("%s/people?withHidden=%v", c.HostURL, withHidden) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + body, err := c.doRequest(req) + if err != nil { + return nil, err + } + + // The API might return a paginated response or a simple array. + // Based on docs, it returns an array of PersonResponseDto or a paginated response. + // Actually, the docs say GET /people returns PeopleResponseDto which has people: PersonResponseDto[] + // Let's check. + var response struct { + People []Person `json:"people"` + } + err = json.Unmarshal(body, &response) + if err != nil { + // Fallback to array if it's not wrapped + var people []Person + if err2 := json.Unmarshal(body, &people); err2 == nil { + return people, nil + } + return nil, err + } + + return response.People, nil +} + +func (c *Client) GetPerson(id string) (*Person, error) { + req, err := http.NewRequest("GET", fmt.Sprintf("%s/people/%s", c.HostURL, id), nil) + if err != nil { + return nil, err + } + + body, err := c.doRequest(req) + if err != nil { + return nil, err + } + + var person Person + err = json.Unmarshal(body, &person) + if err != nil { + return nil, err + } + + return &person, nil +} + +func (c *Client) UpdatePerson(id string, person UpdatePersonRequest) (*Person, error) { + rb, err := json.Marshal(person) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("PATCH", fmt.Sprintf("%s/people/%s", c.HostURL, id), bytes.NewBuffer(rb)) + if err != nil { + return nil, err + } + + body, err := c.doRequest(req) + if err != nil { + return nil, err + } + + var updatedPerson Person + err = json.Unmarshal(body, &updatedPerson) + if err != nil { + return nil, err + } + + return &updatedPerson, nil +} + +func (c *Client) DeletePerson(id string) error { + req, err := http.NewRequest("DELETE", fmt.Sprintf("%s/people/%s", c.HostURL, id), nil) + if err != nil { + return err + } + + _, err = c.doRequest(req) + return err +} diff --git a/internal/provider/person_resource.go b/internal/provider/person_resource.go new file mode 100644 index 0000000..226cf0e --- /dev/null +++ b/internal/provider/person_resource.go @@ -0,0 +1,210 @@ +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/booldefault" + "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/immich-app/terraform-provider-immich/internal/client" +) + +// Ensure the implementation satisfies the expected interfaces. +var _ resource.Resource = &personResource{} +var _ resource.ResourceWithImportState = &personResource{} + +func NewPersonResource() resource.Resource { + return &personResource{} +} + +// personResource defines the resource implementation. +type personResource struct { + client *client.Client +} + +// personResourceModel describes the resource data model. +type personResourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + BirthDate types.String `tfsdk:"birth_date"` + IsHidden types.Bool `tfsdk:"is_hidden"` + IsFavorite types.Bool `tfsdk:"is_favorite"` +} + +func (r *personResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_person" +} + +func (r *personResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Manages an Immich person. Note: Persons are usually created automatically by Immich facial recognition. This resource is used to update their details.", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Required: true, + MarkdownDescription: "Unique identifier for the person.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "name": schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "Name of the person.", + }, + "birth_date": schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "Birth date of the person (ISO 8601).", + }, + "is_hidden": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + MarkdownDescription: "Whether the person is hidden from the UI.", + }, + "is_favorite": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + MarkdownDescription: "Whether the person is marked as a favorite.", + }, + }, + } +} + +func (r *personResource) 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 *personResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + // Persons are not created via POST /people, but usually discovered. + // We'll treat Create as an Update if the person exists. + var data personResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + isHidden := data.IsHidden.ValueBool() + isFavorite := data.IsFavorite.ValueBool() + + updateReq := client.UpdatePersonRequest{ + Name: data.Name.ValueString(), + BirthDate: data.BirthDate.ValueString(), + IsHidden: &isHidden, + IsFavorite: &isFavorite, + } + + person, err := r.client.UpdatePerson(data.ID.ValueString(), updateReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update person, got error: %s", err)) + return + } + + data.Name = types.StringValue(person.Name) + data.BirthDate = types.StringValue(person.BirthDate) + data.IsHidden = types.BoolValue(person.IsHidden) + data.IsFavorite = types.BoolValue(person.IsFavorite) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *personResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data personResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + person, err := r.client.GetPerson(data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read person, got error: %s", err)) + return + } + + data.Name = types.StringValue(person.Name) + data.BirthDate = types.StringValue(person.BirthDate) + data.IsHidden = types.BoolValue(person.IsHidden) + data.IsFavorite = types.BoolValue(person.IsFavorite) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *personResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data personResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + isHidden := data.IsHidden.ValueBool() + isFavorite := data.IsFavorite.ValueBool() + + updateReq := client.UpdatePersonRequest{ + Name: data.Name.ValueString(), + BirthDate: data.BirthDate.ValueString(), + IsHidden: &isHidden, + IsFavorite: &isFavorite, + } + + person, err := r.client.UpdatePerson(data.ID.ValueString(), updateReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update person, got error: %s", err)) + return + } + + data.Name = types.StringValue(person.Name) + data.BirthDate = types.StringValue(person.BirthDate) + data.IsHidden = types.BoolValue(person.IsHidden) + data.IsFavorite = types.BoolValue(person.IsFavorite) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *personResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // Deleting a person in Immich usually just removes the person record, faces remain. + var data personResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + err := r.client.DeletePerson(data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete person, got error: %s", err)) + return + } +} + +func (r *personResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index e85d99d..b3d2289 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -93,6 +93,7 @@ func (p *immichProvider) Resources(ctx context.Context) []func() resource.Resour NewSystemConfigResource, NewLibraryResource, NewActivityResource, + NewPersonResource, } }