From bcc8a1a47d2859924801b6a90bc14217b7371359 Mon Sep 17 00:00:00 2001 From: Russ Long Date: Mon, 1 Jun 2026 11:21:32 -0400 Subject: [PATCH] feat: implement library resource and data source --- docs/data-sources/libraries.md | 43 +++ docs/resources/library.md | 54 ++++ .../immich_libraries/data-source.tf | 9 + examples/resources/immich_library/resource.tf | 18 ++ internal/client/library.go | 140 ++++++++++ internal/provider/libraries_data_source.go | 119 +++++++++ internal/provider/library_resource.go | 248 ++++++++++++++++++ internal/provider/provider.go | 2 + 8 files changed, 633 insertions(+) create mode 100644 docs/data-sources/libraries.md create mode 100644 docs/resources/library.md create mode 100644 examples/data-sources/immich_libraries/data-source.tf create mode 100644 examples/resources/immich_library/resource.tf create mode 100644 internal/client/library.go create mode 100644 internal/provider/libraries_data_source.go create mode 100644 internal/provider/library_resource.go diff --git a/docs/data-sources/libraries.md b/docs/data-sources/libraries.md new file mode 100644 index 0000000..b8cc230 --- /dev/null +++ b/docs/data-sources/libraries.md @@ -0,0 +1,43 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "immich_libraries Data Source - terraform-provider-immich" +subcategory: "" +description: |- + Retrieves a list of all Immich libraries. +--- + +# immich_libraries (Data Source) + +Retrieves a list of all Immich libraries. + +## Example Usage + +```terraform +data "immich_libraries" "all" {} + +output "library_names" { + value = data.immich_libraries.all.libraries[*].name +} + +output "external_libraries" { + value = [for l in data.immich_libraries.all.libraries : l if l.type == "EXTERNAL"] +} +``` + + +## Schema + +### Read-Only + +- `libraries` (Attributes List) List of libraries. (see [below for nested schema](#nestedatt--libraries)) + + +### Nested Schema for `libraries` + +Read-Only: + +- `asset_count` (Number) Number of assets in the library. +- `id` (String) Unique identifier for the library. +- `name` (String) Display name of the library. +- `owner_id` (String) Unique identifier of the library owner. +- `type` (String) Type of the library (UPLOAD or EXTERNAL). diff --git a/docs/resources/library.md b/docs/resources/library.md new file mode 100644 index 0000000..77ae850 --- /dev/null +++ b/docs/resources/library.md @@ -0,0 +1,54 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "immich_library Resource - terraform-provider-immich" +subcategory: "" +description: |- + Manages an Immich library. +--- + +# immich_library (Resource) + +Manages an Immich library. + +## Example Usage + +```terraform +resource "immich_library" "external" { + name = "My External Photos" + type = "EXTERNAL" + import_paths = [ + "/mnt/photos/vacation_2023", + "/mnt/photos/family" + ] + exclusion_patterns = [ + "**/tmp/**", + "**/.DS_Store" + ] + is_visible = true +} + +resource "immich_library" "upload" { + name = "Personal Uploads" + type = "UPLOAD" +} +``` + + +## Schema + +### Required + +- `name` (String) Display name of the library. +- `type` (String) Type of the library. Must be either `UPLOAD` or `EXTERNAL`. + +### Optional + +- `exclusion_patterns` (List of String) List of glob patterns to exclude from import. +- `import_paths` (List of String) List of filesystem paths to import assets from (required for `EXTERNAL` libraries). +- `is_visible` (Boolean) Whether the library is visible in the UI. + +### Read-Only + +- `asset_count` (Number) Number of assets currently in the library. +- `id` (String) Unique identifier for the library. +- `owner_id` (String) Unique identifier of the library owner. diff --git a/examples/data-sources/immich_libraries/data-source.tf b/examples/data-sources/immich_libraries/data-source.tf new file mode 100644 index 0000000..ccf3a3d --- /dev/null +++ b/examples/data-sources/immich_libraries/data-source.tf @@ -0,0 +1,9 @@ +data "immich_libraries" "all" {} + +output "library_names" { + value = data.immich_libraries.all.libraries[*].name +} + +output "external_libraries" { + value = [for l in data.immich_libraries.all.libraries : l if l.type == "EXTERNAL"] +} diff --git a/examples/resources/immich_library/resource.tf b/examples/resources/immich_library/resource.tf new file mode 100644 index 0000000..8a668a0 --- /dev/null +++ b/examples/resources/immich_library/resource.tf @@ -0,0 +1,18 @@ +resource "immich_library" "external" { + name = "My External Photos" + type = "EXTERNAL" + import_paths = [ + "/mnt/photos/vacation_2023", + "/mnt/photos/family" + ] + exclusion_patterns = [ + "**/tmp/**", + "**/.DS_Store" + ] + is_visible = true +} + +resource "immich_library" "upload" { + name = "Personal Uploads" + type = "UPLOAD" +} diff --git a/internal/client/library.go b/internal/client/library.go new file mode 100644 index 0000000..a20d707 --- /dev/null +++ b/internal/client/library.go @@ -0,0 +1,140 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" +) + +type Library struct { + ID string `json:"id"` + OwnerId string `json:"ownerId"` + Name string `json:"name"` + Type string `json:"type"` + ImportPaths []string `json:"importPaths"` + ExclusionPatterns []string `json:"exclusionPatterns"` + AssetCount int `json:"assetCount"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + RefreshedAt string `json:"refreshedAt"` +} + +type CreateLibraryRequest struct { + Name string `json:"name"` + Type string `json:"type"` + ImportPaths []string `json:"importPaths"` + ExclusionPatterns []string `json:"exclusionPatterns"` + IsVisible bool `json:"isVisible"` +} + +type UpdateLibraryRequest struct { + Name string `json:"name,omitempty"` + ImportPaths []string `json:"importPaths,omitempty"` + ExclusionPatterns []string `json:"exclusionPatterns,omitempty"` + IsVisible *bool `json:"isVisible,omitempty"` +} + +func (c *Client) GetLibraries() ([]Library, error) { + req, err := http.NewRequest("GET", fmt.Sprintf("%s/library", c.HostURL), nil) + if err != nil { + return nil, err + } + + body, err := c.doRequest(req) + if err != nil { + return nil, err + } + + var libraries []Library + err = json.Unmarshal(body, &libraries) + if err != nil { + return nil, err + } + + return libraries, nil +} + +func (c *Client) GetLibrary(id string) (*Library, error) { + req, err := http.NewRequest("GET", fmt.Sprintf("%s/library/%s", c.HostURL, id), nil) + if err != nil { + return nil, err + } + + body, err := c.doRequest(req) + if err != nil { + return nil, err + } + + var library Library + err = json.Unmarshal(body, &library) + if err != nil { + return nil, err + } + + return &library, nil +} + +func (c *Client) CreateLibrary(library CreateLibraryRequest) (*Library, error) { + rb, err := json.Marshal(library) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", fmt.Sprintf("%s/library", c.HostURL), bytes.NewBuffer(rb)) + if err != nil { + return nil, err + } + + body, err := c.doRequest(req) + if err != nil { + return nil, err + } + + var newLibrary Library + err = json.Unmarshal(body, &newLibrary) + if err != nil { + return nil, err + } + + return &newLibrary, nil +} + +func (c *Client) UpdateLibrary(id string, library UpdateLibraryRequest) (*Library, error) { + rb, err := json.Marshal(library) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("PUT", fmt.Sprintf("%s/library/%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 updatedLibrary Library + err = json.Unmarshal(body, &updatedLibrary) + if err != nil { + return nil, err + } + + return &updatedLibrary, nil +} + +func (c *Client) DeleteLibrary(id string) error { + req, err := http.NewRequest("DELETE", fmt.Sprintf("%s/library/%s", c.HostURL, id), nil) + if err != nil { + return err + } + + _, err = c.doRequest(req) + if err != nil { + return err + } + + return nil +} diff --git a/internal/provider/libraries_data_source.go b/internal/provider/libraries_data_source.go new file mode 100644 index 0000000..6de0ee5 --- /dev/null +++ b/internal/provider/libraries_data_source.go @@ -0,0 +1,119 @@ +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/immich-app/terraform-provider-immich/internal/client" +) + +// Ensure the implementation satisfies the expected interfaces. +var _ datasource.DataSource = &librariesDataSource{} + +func NewLibrariesDataSource() datasource.DataSource { + return &librariesDataSource{} +} + +// librariesDataSource defines the data source implementation. +type librariesDataSource struct { + client *client.Client +} + +// librariesDataSourceModel describes the data source data model. +type librariesDataSourceModel struct { + Libraries []librariesModel `tfsdk:"libraries"` +} + +type librariesModel struct { + ID types.String `tfsdk:"id"` + OwnerId types.String `tfsdk:"owner_id"` + Name types.String `tfsdk:"name"` + Type types.String `tfsdk:"type"` + AssetCount types.Int64 `tfsdk:"asset_count"` +} + +func (d *librariesDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_libraries" +} + +func (d *librariesDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Retrieves a list of all Immich libraries.", + + Attributes: map[string]schema.Attribute{ + "libraries": schema.ListNestedAttribute{ + Computed: true, + MarkdownDescription: "List of libraries.", + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Unique identifier for the library.", + }, + "owner_id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Unique identifier of the library owner.", + }, + "name": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Display name of the library.", + }, + "type": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Type of the library (UPLOAD or EXTERNAL).", + }, + "asset_count": schema.Int64Attribute{ + Computed: true, + MarkdownDescription: "Number of assets in the library.", + }, + }, + }, + }, + }, + } +} + +func (d *librariesDataSource) 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 *librariesDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data librariesDataSourceModel + + libraries, err := d.client.GetLibraries() + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read libraries, got error: %s", err)) + return + } + + for _, library := range libraries { + libraryState := librariesModel{ + ID: types.StringValue(library.ID), + OwnerId: types.StringValue(library.OwnerId), + Name: types.StringValue(library.Name), + Type: types.StringValue(library.Type), + AssetCount: types.Int64Value(int64(library.AssetCount)), + } + data.Libraries = append(data.Libraries, libraryState) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/internal/provider/library_resource.go b/internal/provider/library_resource.go new file mode 100644 index 0000000..b8d79c0 --- /dev/null +++ b/internal/provider/library_resource.go @@ -0,0 +1,248 @@ +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 = &libraryResource{} +var _ resource.ResourceWithImportState = &libraryResource{} + +func NewLibraryResource() resource.Resource { + return &libraryResource{} +} + +// libraryResource defines the resource implementation. +type libraryResource struct { + client *client.Client +} + +// libraryResourceModel describes the resource data model. +type libraryResourceModel struct { + ID types.String `tfsdk:"id"` + OwnerId types.String `tfsdk:"owner_id"` + Name types.String `tfsdk:"name"` + Type types.String `tfsdk:"type"` + ImportPaths []types.String `tfsdk:"import_paths"` + ExclusionPatterns []types.String `tfsdk:"exclusion_patterns"` + IsVisible types.Bool `tfsdk:"is_visible"` + AssetCount types.Int64 `tfsdk:"asset_count"` +} + +func (r *libraryResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_library" +} + +func (r *libraryResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Manages an Immich library.", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Unique identifier for the library.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "owner_id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Unique identifier of the library owner.", + }, + "name": schema.StringAttribute{ + Required: true, + MarkdownDescription: "Display name of the library.", + }, + "type": schema.StringAttribute{ + Required: true, + MarkdownDescription: "Type of the library. Must be either `UPLOAD` or `EXTERNAL`.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "import_paths": schema.ListAttribute{ + ElementType: types.StringType, + Optional: true, + MarkdownDescription: "List of filesystem paths to import assets from (required for `EXTERNAL` libraries).", + }, + "exclusion_patterns": schema.ListAttribute{ + ElementType: types.StringType, + Optional: true, + MarkdownDescription: "List of glob patterns to exclude from import.", + }, + "is_visible": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + MarkdownDescription: "Whether the library is visible in the UI.", + }, + "asset_count": schema.Int64Attribute{ + Computed: true, + MarkdownDescription: "Number of assets currently in the library.", + }, + }, + } +} + +func (r *libraryResource) 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 *libraryResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data libraryResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + importPaths := make([]string, len(data.ImportPaths)) + for i, p := range data.ImportPaths { + importPaths[i] = p.ValueString() + } + + exclusionPatterns := make([]string, len(data.ExclusionPatterns)) + for i, p := range data.ExclusionPatterns { + exclusionPatterns[i] = p.ValueString() + } + + createReq := client.CreateLibraryRequest{ + Name: data.Name.ValueString(), + Type: data.Type.ValueString(), + ImportPaths: importPaths, + ExclusionPatterns: exclusionPatterns, + IsVisible: data.IsVisible.ValueBool(), + } + + library, err := r.client.CreateLibrary(createReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create library, got error: %s", err)) + return + } + + data.ID = types.StringValue(library.ID) + data.OwnerId = types.StringValue(library.OwnerId) + data.AssetCount = types.Int64Value(int64(library.AssetCount)) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *libraryResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data libraryResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + library, err := r.client.GetLibrary(data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read library, got error: %s", err)) + return + } + + data.Name = types.StringValue(library.Name) + data.Type = types.StringValue(library.Type) + data.OwnerId = types.StringValue(library.OwnerId) + data.AssetCount = types.Int64Value(int64(library.AssetCount)) + + importPaths := make([]types.String, len(library.ImportPaths)) + for i, p := range library.ImportPaths { + importPaths[i] = types.StringValue(p) + } + data.ImportPaths = importPaths + + exclusionPatterns := make([]types.String, len(library.ExclusionPatterns)) + for i, p := range library.ExclusionPatterns { + exclusionPatterns[i] = types.StringValue(p) + } + data.ExclusionPatterns = exclusionPatterns + + // Note: isVisible is not returned in LibraryResponseDto, we'll keep what's in state + // or assume it's true if not available. + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *libraryResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data libraryResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + importPaths := make([]string, len(data.ImportPaths)) + for i, p := range data.ImportPaths { + importPaths[i] = p.ValueString() + } + + exclusionPatterns := make([]string, len(data.ExclusionPatterns)) + for i, p := range data.ExclusionPatterns { + exclusionPatterns[i] = p.ValueString() + } + + isVisible := data.IsVisible.ValueBool() + updateReq := client.UpdateLibraryRequest{ + Name: data.Name.ValueString(), + ImportPaths: importPaths, + ExclusionPatterns: exclusionPatterns, + IsVisible: &isVisible, + } + + _, err := r.client.UpdateLibrary(data.ID.ValueString(), updateReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update library, got error: %s", err)) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *libraryResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data libraryResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + err := r.client.DeleteLibrary(data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete library, got error: %s", err)) + return + } +} + +func (r *libraryResource) 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 7b026a3..0d5a3c9 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -91,6 +91,7 @@ func (p *immichProvider) Resources(ctx context.Context) []func() resource.Resour NewSharedLinkResource, NewAlbumResource, NewSystemConfigResource, + NewLibraryResource, } } @@ -98,6 +99,7 @@ func (p *immichProvider) DataSources(ctx context.Context) []func() datasource.Da return []func() datasource.DataSource{ NewUsersDataSource, NewAlbumsDataSource, + NewLibrariesDataSource, } }