ultisuite-backend/internal/photos/client.go
R3D347HR4Y f0f0b31043 Implement Photos API robustness and quota integration
Improve Immich-backed photos endpoints with robust mapping/error handling, full albums CRUD, reliable list pagination/sorting/filtering, and shared Nextcloud quota checks before upload.
2026-05-22 21:09:13 +02:00

220 lines
5.4 KiB
Go

package photos
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type Client struct {
baseURL string
httpClient *http.Client
}
func NewClient(baseURL string) *Client {
return &Client{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
}
}
type Asset struct {
ID string `json:"id"`
Type string `json:"type"`
OriginalPath string `json:"originalPath"`
OriginalName string `json:"originalFileName"`
MimeType string `json:"originalMimeType"`
FileSize int64 `json:"fileSizeInByte"`
CreatedAt string `json:"fileCreatedAt"`
IsFavorite bool `json:"isFavorite"`
ThumbHash string `json:"thumbhash"`
ExifInfo struct {
FileSizeInByte int64 `json:"fileSizeInByte"`
} `json:"exifInfo,omitempty"`
}
type Album struct {
ID string `json:"id"`
Name string `json:"albumName"`
Description string `json:"description,omitempty"`
AssetCount int `json:"assetCount"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
const assetsPageSize = 200
func (c *Client) GetAllAssets(ctx context.Context, apiKey string) ([]Asset, error) {
var all []Asset
page := 1
for {
batch, err := c.GetAssets(ctx, apiKey, page, assetsPageSize)
if err != nil {
return nil, err
}
all = append(all, batch...)
if len(batch) < assetsPageSize {
break
}
page++
}
return all, nil
}
func (c *Client) GetAssets(ctx context.Context, apiKey string, page, size int) ([]Asset, error) {
url := fmt.Sprintf("%s/assets?page=%d&size=%d", c.baseURL, page, size)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("x-api-key", apiKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
io.Copy(io.Discard, resp.Body)
return nil, &HTTPStatusError{Operation: "get assets", StatusCode: resp.StatusCode}
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var assets []Asset
trimmed := bytes.TrimSpace(body)
switch {
case len(trimmed) == 0:
return []Asset{}, nil
case trimmed[0] == '[':
if err := json.Unmarshal(trimmed, &assets); err != nil {
return nil, err
}
default:
var wrapped struct {
Items []Asset `json:"items"`
Assets []Asset `json:"assets"`
}
if err := json.Unmarshal(trimmed, &wrapped); err != nil {
return nil, err
}
if len(wrapped.Items) > 0 {
assets = wrapped.Items
} else {
assets = wrapped.Assets
}
}
for i := range assets {
if assets[i].FileSize == 0 {
assets[i].FileSize = assets[i].ExifInfo.FileSizeInByte
}
}
return assets, nil
}
func (c *Client) GetAlbums(ctx context.Context, apiKey string) ([]Album, error) {
url := fmt.Sprintf("%s/albums", c.baseURL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("x-api-key", apiKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
io.Copy(io.Discard, resp.Body)
return nil, &HTTPStatusError{Operation: "get albums", StatusCode: resp.StatusCode}
}
var albums []Album
if err := json.NewDecoder(resp.Body).Decode(&albums); err != nil {
return nil, err
}
return albums, nil
}
func (c *Client) UploadAsset(ctx context.Context, apiKey string, body io.Reader, contentType string) (string, error) {
url := fmt.Sprintf("%s/assets", c.baseURL)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
if err != nil {
return "", err
}
req.Header.Set("x-api-key", apiKey)
req.Header.Set("Content-Type", contentType)
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
io.Copy(io.Discard, resp.Body)
return "", &HTTPStatusError{Operation: "upload asset", StatusCode: resp.StatusCode}
}
var result struct {
ID string `json:"id"`
AssetID string `json:"assetId"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if result.ID != "" {
return result.ID, nil
}
return result.AssetID, nil
}
func (c *Client) GetAssetThumbnail(ctx context.Context, apiKey, assetID string) (io.ReadCloser, string, error) {
url := fmt.Sprintf("%s/assets/%s/thumbnail", c.baseURL, assetID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, "", err
}
req.Header.Set("x-api-key", apiKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, "", err
}
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
return nil, "", &HTTPStatusError{Operation: "get asset thumbnail", StatusCode: resp.StatusCode}
}
return resp.Body, resp.Header.Get("Content-Type"), nil
}
func (c *Client) DeleteAsset(ctx context.Context, apiKey, assetID string) error {
url := fmt.Sprintf("%s/assets/%s", c.baseURL, assetID)
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
if err != nil {
return err
}
req.Header.Set("x-api-key", apiKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
io.Copy(io.Discard, resp.Body)
return &HTTPStatusError{Operation: "delete asset", StatusCode: resp.StatusCode}
}
return nil
}