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.
This commit is contained in:
R3D347HR4Y 2026-05-22 21:09:13 +02:00
parent f232aaf960
commit f0f0b31043
13 changed files with 1533 additions and 67 deletions

View File

@ -152,6 +152,9 @@ IMMICH_ENABLED=true
# Ce flag pilote aussi le lancement Docker via ./deploy/compose-up.sh
# Immich utilise son propre Postgres (pgvecto.rs) via immich-postgres
IMMICH_API_URL=http://immich-server:2283/api
# Quota photos partagé avec Ultidrive (0 = désactivé)
ULTID_PHOTOS_MAX_UPLOAD_BYTES=0
ULTID_PHOTOS_QUOTA_RESERVED_BYTES=0
IMMICH_DB_NAME=immich
IMMICH_UPLOAD_LOCATION=/upload

View File

@ -215,7 +215,7 @@ func main() {
r.Mount("/api/v1/meet", meetapi.NewHandler(meetCfg).Routes())
}
if photosClient != nil {
r.Mount("/api/v1/photos", photosapi.NewHandler(photosClient).Routes())
r.Mount("/api/v1/photos", photosapi.NewHandler(photosClient, ncClient).Routes())
}
})

View File

@ -1,6 +1,8 @@
package photos
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
@ -11,8 +13,9 @@ import (
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/api/middleware"
"github.com/ultisuite/ulti-backend/internal/api/query"
photospkg "github.com/ultisuite/ulti-backend/internal/photos"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
"github.com/ultisuite/ulti-backend/internal/permission"
photospkg "github.com/ultisuite/ulti-backend/internal/photos"
)
type Handler struct {
@ -20,9 +23,11 @@ type Handler struct {
logger *slog.Logger
}
func NewHandler(client *photospkg.Client) *Handler {
type contentLengthContextKey struct{}
func NewHandler(client *photospkg.Client, quota *nextcloud.Client) *Handler {
return &Handler{
svc: NewService(client),
svc: NewService(client, quota),
logger: slog.Default().With("component", "photos-api"),
}
}
@ -35,8 +40,14 @@ func (h *Handler) Routes() chi.Router {
r.With(read).Get("/assets", h.ListAssets)
r.With(read).Get("/assets/{assetID}/thumbnail", h.Thumbnail)
r.With(read).Get("/albums", h.ListAlbums)
r.With(read).Get("/albums/{albumID}", h.GetAlbum)
r.With(write).Post("/assets", h.Upload)
r.With(write).Post("/albums", h.CreateAlbum)
r.With(write).Patch("/albums/{albumID}", h.UpdateAlbum)
r.With(write).Delete("/assets/{assetID}", h.DeleteAsset)
r.With(write).Delete("/albums/{albumID}", h.DeleteAlbum)
r.With(write).Put("/albums/{albumID}/assets", h.AddAlbumAssets)
r.With(write).Delete("/albums/{albumID}/assets", h.RemoveAlbumAssets)
return r
}
@ -59,11 +70,12 @@ func (h *Handler) ListAssets(w http.ResponseWriter, r *http.Request) {
func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ctx := context.WithValue(r.Context(), contentLengthContextKey{}, r.ContentLength)
id, err := h.svc.UploadAsset(r.Context(), claims.Sub, r.Body, r.Header.Get("Content-Type"))
id, err := h.svc.UploadAsset(ctx, claims.Sub, r.Body, r.Header.Get("Content-Type"))
if err != nil {
h.logger.Error("upload asset", "error", err)
apivalidate.WriteInternal(w, r)
writePhotosError(w, r, err)
return
}
apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"id": id})
@ -79,7 +91,7 @@ func (h *Handler) Thumbnail(w http.ResponseWriter, r *http.Request) {
body, ct, err := h.svc.GetAssetThumbnail(r.Context(), claims.Sub, assetID)
if err != nil {
apivalidate.WriteNotFound(w, r, "not found")
writePhotosError(w, r, err)
return
}
defer body.Close()
@ -98,7 +110,7 @@ func (h *Handler) DeleteAsset(w http.ResponseWriter, r *http.Request) {
if err := h.svc.DeleteAsset(r.Context(), claims.Sub, assetID); err != nil {
h.logger.Error("delete asset", "error", err)
apivalidate.WriteInternal(w, r)
writePhotosError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
@ -115,8 +127,177 @@ func (h *Handler) ListAlbums(w http.ResponseWriter, r *http.Request) {
result, err := h.svc.ListAlbums(r.Context(), claims.Sub, params)
if err != nil {
h.logger.Error("list albums", "error", err)
apivalidate.WriteInternal(w, r)
writePhotosError(w, r, err)
return
}
apiresponse.WriteJSON(w, http.StatusOK, result)
}
func (h *Handler) GetAlbum(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
albumID := chi.URLParam(r, "albumID")
if verr := validateAlbumID(albumID); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
album, err := h.svc.GetAlbum(r.Context(), claims.Sub, albumID)
if err != nil {
if errors.Is(err, ErrNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
return
}
h.logger.Error("get album", "error", err)
writePhotosError(w, r, err)
return
}
apiresponse.WriteJSON(w, http.StatusOK, album)
}
func (h *Handler) CreateAlbum(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
var req createAlbumRequest
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
return
}
if verr := validateCreateAlbum(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
album, err := h.svc.CreateAlbum(r.Context(), claims.Sub, &req)
if err != nil {
h.logger.Error("create album", "error", err)
writePhotosError(w, r, err)
return
}
apiresponse.WriteJSON(w, http.StatusCreated, album)
}
func (h *Handler) UpdateAlbum(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
albumID := chi.URLParam(r, "albumID")
if verr := validateAlbumID(albumID); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
var req updateAlbumRequest
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
return
}
if verr := validateUpdateAlbum(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
album, err := h.svc.UpdateAlbum(r.Context(), claims.Sub, albumID, &req)
if err != nil {
if errors.Is(err, ErrNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
return
}
h.logger.Error("update album", "error", err)
writePhotosError(w, r, err)
return
}
apiresponse.WriteJSON(w, http.StatusOK, album)
}
func (h *Handler) DeleteAlbum(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
albumID := chi.URLParam(r, "albumID")
if verr := validateAlbumID(albumID); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
if err := h.svc.DeleteAlbum(r.Context(), claims.Sub, albumID); err != nil {
if errors.Is(err, ErrNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
return
}
h.logger.Error("delete album", "error", err)
writePhotosError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) AddAlbumAssets(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
albumID := chi.URLParam(r, "albumID")
if verr := validateAlbumID(albumID); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
var req albumAssetsRequest
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
return
}
if verr := validateAlbumAssetsRequest(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
result, err := h.svc.AddAlbumAssets(r.Context(), claims.Sub, albumID, req.AssetIDs)
if err != nil {
if errors.Is(err, ErrNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
return
}
h.logger.Error("add album assets", "error", err)
writePhotosError(w, r, err)
return
}
apiresponse.WriteJSON(w, http.StatusOK, result)
}
func (h *Handler) RemoveAlbumAssets(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
albumID := chi.URLParam(r, "albumID")
if verr := validateAlbumID(albumID); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
var req albumAssetsRequest
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
return
}
if verr := validateAlbumAssetsRequest(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
result, err := h.svc.RemoveAlbumAssets(r.Context(), claims.Sub, albumID, req.AssetIDs)
if err != nil {
if errors.Is(err, ErrNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
return
}
h.logger.Error("remove album assets", "error", err)
writePhotosError(w, r, err)
return
}
apiresponse.WriteJSON(w, http.StatusOK, result)
}
func writePhotosError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, ErrNotFound):
apivalidate.WriteNotFound(w, r, "not found")
case errors.Is(err, ErrConflict):
apiresponse.WriteError(w, r, http.StatusConflict, "photos.conflict", "resource conflict", nil)
case errors.Is(err, ErrForbidden):
apiresponse.WriteError(w, r, http.StatusForbidden, "photos.forbidden", "forbidden", nil)
case errors.Is(err, ErrQuotaExceeded):
apiresponse.WriteError(w, r, http.StatusInsufficientStorage, "photos.quota_exceeded", "quota exceeded", nil)
case errors.Is(err, ErrInvalid):
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid request body", nil)
default:
apivalidate.WriteInternal(w, r)
}
}

173
internal/api/photos/list.go Normal file
View File

@ -0,0 +1,173 @@
package photos
import (
"sort"
"strings"
"time"
"github.com/ultisuite/ulti-backend/internal/api/paginate"
"github.com/ultisuite/ulti-backend/internal/api/query"
photospkg "github.com/ultisuite/ulti-backend/internal/photos"
)
var photosTimeFormats = []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05.000Z",
"2006-01-02",
}
func parsePhotosTime(raw string) (time.Time, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, false
}
for _, layout := range photosTimeFormats {
if t, err := time.Parse(layout, raw); err == nil {
return t.UTC(), true
}
}
return time.Time{}, false
}
func parseSortField(sort string) (field string, desc bool) {
sort = strings.TrimSpace(sort)
if sort == "" {
return "created_at", true
}
if strings.HasPrefix(sort, "-") {
return strings.TrimPrefix(sort, "-"), true
}
return sort, false
}
func assetMatchesDate(a photospkg.Asset, from, to *time.Time) bool {
if from == nil && to == nil {
return true
}
created, ok := parsePhotosTime(a.CreatedAt)
if !ok {
return false
}
if from != nil && created.Before(*from) {
return false
}
if to != nil && created.After(*to) {
return false
}
return true
}
func albumMatchesDate(a photospkg.Album, from, to *time.Time) bool {
if from == nil && to == nil {
return true
}
created, ok := parsePhotosTime(a.CreatedAt)
if !ok {
return false
}
if from != nil && created.Before(*from) {
return false
}
if to != nil && created.After(*to) {
return false
}
return true
}
func filterAssets(assets []photospkg.Asset, params query.ListParams) []photospkg.Asset {
q := strings.ToLower(strings.TrimSpace(params.Q))
out := make([]photospkg.Asset, 0, len(assets))
for _, a := range assets {
if q != "" {
if !strings.Contains(strings.ToLower(a.OriginalName), q) &&
!strings.Contains(strings.ToLower(a.MimeType), q) {
continue
}
}
if !assetMatchesDate(a, params.From, params.To) {
continue
}
out = append(out, a)
}
return out
}
func filterAlbums(albums []photospkg.Album, params query.ListParams) []photospkg.Album {
q := strings.ToLower(strings.TrimSpace(params.Q))
out := make([]photospkg.Album, 0, len(albums))
for _, a := range albums {
if q != "" && !strings.Contains(strings.ToLower(a.Name), q) {
continue
}
if !albumMatchesDate(a, params.From, params.To) {
continue
}
out = append(out, a)
}
return out
}
func sortAssets(assets []photospkg.Asset, sortParam string) {
field, desc := parseSortField(sortParam)
sort.SliceStable(assets, func(i, j int) bool {
if desc {
return compareAssets(assets[j], assets[i], field)
}
return compareAssets(assets[i], assets[j], field)
})
}
func compareAssets(a, b photospkg.Asset, field string) bool {
switch field {
case "name":
return strings.ToLower(a.OriginalName) < strings.ToLower(b.OriginalName)
case "size":
return a.FileSize < b.FileSize
default: // created_at
ti, okI := parsePhotosTime(a.CreatedAt)
tj, okJ := parsePhotosTime(b.CreatedAt)
if okI && okJ {
return ti.Before(tj)
}
return a.CreatedAt < b.CreatedAt
}
}
func sortAlbums(albums []photospkg.Album, sortParam string) {
field, desc := parseSortField(sortParam)
sort.SliceStable(albums, func(i, j int) bool {
if desc {
return compareAlbums(albums[j], albums[i], field)
}
return compareAlbums(albums[i], albums[j], field)
})
}
func compareAlbums(a, b photospkg.Album, field string) bool {
switch field {
case "name":
return strings.ToLower(a.Name) < strings.ToLower(b.Name)
case "size":
return a.AssetCount < b.AssetCount
default: // created_at
ti, okI := parsePhotosTime(a.CreatedAt)
tj, okJ := parsePhotosTime(b.CreatedAt)
if okI && okJ {
return ti.Before(tj)
}
return a.CreatedAt < b.CreatedAt
}
}
func applyAssetList(assets []photospkg.Asset, params query.ListParams) ([]photospkg.Asset, int64) {
filtered := filterAssets(assets, params)
sortAssets(filtered, params.Sort)
return paginate.Slice(filtered, params.Offset(), params.Limit())
}
func applyAlbumList(albums []photospkg.Album, params query.ListParams) ([]photospkg.Album, int64) {
filtered := filterAlbums(albums, params)
sortAlbums(filtered, params.Sort)
return paginate.Slice(filtered, params.Offset(), params.Limit())
}

View File

@ -2,20 +2,44 @@ package photos
import (
"context"
"errors"
"io"
"net/http"
"os"
"strconv"
"strings"
"github.com/ultisuite/ulti-backend/internal/api/paginate"
"github.com/ultisuite/ulti-backend/internal/api/query"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
photospkg "github.com/ultisuite/ulti-backend/internal/photos"
)
type Service struct {
client *photospkg.Client
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
ErrInvalid = errors.New("invalid request")
ErrForbidden = errors.New("forbidden")
ErrQuotaExceeded = errors.New("quota exceeded")
)
type quotaProvider interface {
GetQuota(ctx context.Context, userID string) (nextcloud.UserQuota, error)
}
func NewService(client *photospkg.Client) *Service {
return &Service{client: client}
type Service struct {
client *photospkg.Client
quota quotaProvider
maxUploadBytes int64
quotaReserveByte int64
}
func NewService(client *photospkg.Client, quota quotaProvider) *Service {
return &Service{
client: client,
quota: quota,
maxUploadBytes: envInt64("ULTID_PHOTOS_MAX_UPLOAD_BYTES", 0),
quotaReserveByte: envInt64("ULTID_PHOTOS_QUOTA_RESERVED_BYTES", 0),
}
}
type AssetsList struct {
@ -25,13 +49,11 @@ type AssetsList struct {
}
func (s *Service) ListAssets(ctx context.Context, apiKey string, params query.ListParams) (AssetsList, error) {
assets, err := s.client.GetAssets(ctx, apiKey, params.Page, params.PageSize)
assets, err := s.client.GetAllAssets(ctx, apiKey)
if err != nil {
return AssetsList{}, err
return AssetsList{}, mapPhotosError(err)
}
filtered := filterAssets(assets, params.Q)
total := int64(len(filtered))
page, _ := paginate.Slice(filtered, 0, len(filtered))
page, total := applyAssetList(assets, params)
return AssetsList{
Assets: page,
Page: params.Page,
@ -47,53 +69,153 @@ type AlbumsList struct {
func (s *Service) ListAlbums(ctx context.Context, apiKey string, params query.ListParams) (AlbumsList, error) {
albums, err := s.client.GetAlbums(ctx, apiKey)
if err != nil {
return AlbumsList{}, err
return AlbumsList{}, mapPhotosError(err)
}
filtered := filterAlbums(albums, params.Q)
page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
page, total := applyAlbumList(albums, params)
return AlbumsList{
Albums: page,
Pagination: params.Meta(&total),
}, nil
}
func (s *Service) GetAlbum(ctx context.Context, apiKey, albumID string) (photospkg.Album, error) {
album, err := s.client.GetAlbum(ctx, apiKey, albumID)
if err != nil {
return photospkg.Album{}, mapPhotosError(err)
}
return album, nil
}
func (s *Service) CreateAlbum(ctx context.Context, apiKey string, req *createAlbumRequest) (photospkg.Album, error) {
album, err := s.client.CreateAlbum(ctx, apiKey, strings.TrimSpace(req.Name), strings.TrimSpace(req.Description), req.AssetIDs)
if err != nil {
return photospkg.Album{}, mapPhotosError(err)
}
return album, nil
}
func (s *Service) UpdateAlbum(ctx context.Context, apiKey, albumID string, req *updateAlbumRequest) (photospkg.Album, error) {
var name, description *string
if req.Name != nil {
trimmed := strings.TrimSpace(*req.Name)
name = &trimmed
}
if req.Description != nil {
trimmed := strings.TrimSpace(*req.Description)
description = &trimmed
}
album, err := s.client.UpdateAlbum(ctx, apiKey, albumID, name, description)
if err != nil {
return photospkg.Album{}, mapPhotosError(err)
}
return album, nil
}
func (s *Service) DeleteAlbum(ctx context.Context, apiKey, albumID string) error {
return mapPhotosError(s.client.DeleteAlbum(ctx, apiKey, albumID))
}
type AlbumAssetsResult struct {
Results []photospkg.BulkAssetResult `json:"results"`
}
func (s *Service) AddAlbumAssets(ctx context.Context, apiKey, albumID string, assetIDs []string) (AlbumAssetsResult, error) {
results, err := s.client.AddAlbumAssets(ctx, apiKey, albumID, assetIDs)
if err != nil {
return AlbumAssetsResult{}, mapPhotosError(err)
}
return AlbumAssetsResult{Results: results}, nil
}
func (s *Service) RemoveAlbumAssets(ctx context.Context, apiKey, albumID string, assetIDs []string) (AlbumAssetsResult, error) {
results, err := s.client.RemoveAlbumAssets(ctx, apiKey, albumID, assetIDs)
if err != nil {
return AlbumAssetsResult{}, mapPhotosError(err)
}
return AlbumAssetsResult{Results: results}, nil
}
func mapPhotosError(err error) error {
if err == nil {
return nil
}
var statusErr *photospkg.HTTPStatusError
if !errors.As(err, &statusErr) {
return err
}
switch statusErr.StatusCode {
case http.StatusNotFound:
return ErrNotFound
case http.StatusConflict:
return ErrConflict
case http.StatusForbidden, http.StatusUnauthorized:
return ErrForbidden
case http.StatusInsufficientStorage, http.StatusRequestEntityTooLarge:
return ErrQuotaExceeded
case http.StatusBadRequest, http.StatusUnprocessableEntity:
return ErrInvalid
default:
return err
}
}
func (s *Service) UploadAsset(ctx context.Context, apiKey string, body io.Reader, contentType string) (string, error) {
return s.client.UploadAsset(ctx, apiKey, body, contentType)
if err := s.ensureQuota(ctx, apiKey); err != nil {
return "", err
}
id, err := s.client.UploadAsset(ctx, apiKey, body, contentType)
return id, mapPhotosError(err)
}
func (s *Service) GetAssetThumbnail(ctx context.Context, apiKey, assetID string) (io.ReadCloser, string, error) {
return s.client.GetAssetThumbnail(ctx, apiKey, assetID)
body, contentType, err := s.client.GetAssetThumbnail(ctx, apiKey, assetID)
return body, contentType, mapPhotosError(err)
}
func (s *Service) DeleteAsset(ctx context.Context, apiKey, assetID string) error {
return s.client.DeleteAsset(ctx, apiKey, assetID)
return mapPhotosError(s.client.DeleteAsset(ctx, apiKey, assetID))
}
func filterAssets(assets []photospkg.Asset, q string) []photospkg.Asset {
q = strings.ToLower(strings.TrimSpace(q))
if q == "" {
return assets
}
out := make([]photospkg.Asset, 0, len(assets))
for _, a := range assets {
if strings.Contains(strings.ToLower(a.OriginalName), q) ||
strings.Contains(strings.ToLower(a.MimeType), q) {
out = append(out, a)
}
}
return out
func (s *Service) ensureQuota(ctx context.Context, userID string) error {
if s.quota == nil {
return nil
}
func filterAlbums(albums []photospkg.Album, q string) []photospkg.Album {
q = strings.ToLower(strings.TrimSpace(q))
if q == "" {
return albums
incomingBytes := requestContentLength(ctx)
if incomingBytes > 0 && s.maxUploadBytes > 0 && incomingBytes > s.maxUploadBytes {
return ErrQuotaExceeded
}
out := make([]photospkg.Album, 0, len(albums))
for _, a := range albums {
if strings.Contains(strings.ToLower(a.Name), q) {
out = append(out, a)
if incomingBytes <= 0 {
return nil
}
quota, err := s.quota.GetQuota(ctx, userID)
if err != nil {
return err
}
return out
if quota.Free <= 0 || incomingBytes+s.quotaReserveByte > quota.Free {
return ErrQuotaExceeded
}
return nil
}
func requestContentLength(ctx context.Context) int64 {
v := ctx.Value(contentLengthContextKey{})
n, ok := v.(int64)
if !ok {
return 0
}
return n
}
func envInt64(key string, fallback int64) int64 {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return fallback
}
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return fallback
}
return n
}

View File

@ -0,0 +1,367 @@
package photos
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/ultisuite/ulti-backend/internal/api/query"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
photospkg "github.com/ultisuite/ulti-backend/internal/photos"
)
func sampleAssets() []photospkg.Asset {
return []photospkg.Asset{
{ID: "a1", OriginalName: "alpha.jpg", MimeType: "image/jpeg", FileSize: 100, CreatedAt: "2026-01-01T10:00:00Z"},
{ID: "a2", OriginalName: "beta.png", MimeType: "image/png", FileSize: 300, CreatedAt: "2026-01-15T10:00:00Z"},
{ID: "a3", OriginalName: "gamma.jpg", MimeType: "image/jpeg", FileSize: 200, CreatedAt: "2026-02-01T10:00:00Z"},
{ID: "a4", OriginalName: "delta.tiff", MimeType: "image/tiff", FileSize: 50, CreatedAt: "2026-02-10T10:00:00Z"},
}
}
func sampleAlbums() []photospkg.Album {
return []photospkg.Album{
{ID: "al1", Name: "Alpha Album", AssetCount: 5, CreatedAt: "2026-01-01T10:00:00Z"},
{ID: "al2", Name: "Beta Album", AssetCount: 15, CreatedAt: "2026-01-20T10:00:00Z"},
{ID: "al3", Name: "Gamma Album", AssetCount: 8, CreatedAt: "2026-02-05T10:00:00Z"},
}
}
func mustDate(t *testing.T, raw string) *time.Time {
t.Helper()
d, err := query.ParseDate(raw)
if err != nil {
t.Fatalf("ParseDate(%q): %v", raw, err)
}
return &d
}
func mustEndDate(t *testing.T, raw string) *time.Time {
t.Helper()
d, err := query.ParseDate(raw)
if err != nil {
t.Fatalf("ParseDate(%q): %v", raw, err)
}
end := d.Add(24*time.Hour - time.Nanosecond)
return &end
}
func TestApplyAssetListPagination(t *testing.T) {
params := query.ListParams{Page: 2, PageSize: 2, Sort: "name"}
page, total := applyAssetList(sampleAssets(), params)
if total != 4 {
t.Fatalf("total = %d, want 4", total)
}
if len(page) != 2 {
t.Fatalf("page len = %d, want 2", len(page))
}
if page[0].ID != "a4" || page[1].ID != "a3" {
t.Fatalf("page ids = %q/%q, want a4/a3", page[0].ID, page[1].ID)
}
meta := params.Meta(&total)
if meta.Page != 2 || meta.PageSize != 2 || meta.Total == nil || *meta.Total != 4 {
t.Fatalf("meta = %+v", meta)
}
}
func TestApplyAssetListPaginationBeyondRange(t *testing.T) {
params := query.ListParams{Page: 10, PageSize: 2}
page, total := applyAssetList(sampleAssets(), params)
if total != 4 {
t.Fatalf("total = %d, want 4", total)
}
if len(page) != 0 {
t.Fatalf("page len = %d, want 0", len(page))
}
}
func TestApplyAssetListSortByNameAsc(t *testing.T) {
params := query.ListParams{Page: 1, PageSize: 10, Sort: "name"}
page, _ := applyAssetList(sampleAssets(), params)
if len(page) != 4 {
t.Fatalf("len = %d", len(page))
}
want := []string{"a1", "a2", "a4", "a3"}
for i, id := range want {
if page[i].ID != id {
t.Fatalf("page[%d].ID = %q, want %q", i, page[i].ID, id)
}
}
}
func TestApplyAssetListSortBySizeDesc(t *testing.T) {
params := query.ListParams{Page: 1, PageSize: 10, Sort: "-size"}
page, _ := applyAssetList(sampleAssets(), params)
want := []string{"a2", "a3", "a1", "a4"}
for i, id := range want {
if page[i].ID != id {
t.Fatalf("page[%d].ID = %q, want %q", i, page[i].ID, id)
}
}
}
func TestApplyAssetListSortByCreatedAtDefaultDesc(t *testing.T) {
params := query.ListParams{Page: 1, PageSize: 10}
page, _ := applyAssetList(sampleAssets(), params)
want := []string{"a4", "a3", "a2", "a1"}
for i, id := range want {
if page[i].ID != id {
t.Fatalf("page[%d].ID = %q, want %q", i, page[i].ID, id)
}
}
}
func TestApplyAssetListFilterByQ(t *testing.T) {
params := query.ListParams{Page: 1, PageSize: 10, Q: "jpg"}
page, total := applyAssetList(sampleAssets(), params)
if total != 2 {
t.Fatalf("total = %d, want 2", total)
}
if page[0].ID != "a3" || page[1].ID != "a1" {
t.Fatalf("ids = %q/%q", page[0].ID, page[1].ID)
}
}
func TestApplyAssetListFilterByQMimeType(t *testing.T) {
params := query.ListParams{Page: 1, PageSize: 10, Q: "tiff"}
page, total := applyAssetList(sampleAssets(), params)
if total != 1 || page[0].ID != "a4" {
t.Fatalf("total=%d page=%+v", total, page)
}
}
func TestApplyAssetListFilterByDateRange(t *testing.T) {
params := query.ListParams{
Page: 1,
PageSize: 10,
From: mustDate(t, "2026-01-10"),
To: mustEndDate(t, "2026-02-05"),
}
page, total := applyAssetList(sampleAssets(), params)
if total != 2 {
t.Fatalf("total = %d, want 2", total)
}
if page[0].ID != "a3" || page[1].ID != "a2" {
t.Fatalf("ids = %q/%q", page[0].ID, page[1].ID)
}
}
func TestApplyAlbumListPagination(t *testing.T) {
params := query.ListParams{Page: 1, PageSize: 2, Sort: "name"}
page, total := applyAlbumList(sampleAlbums(), params)
if total != 3 || len(page) != 2 {
t.Fatalf("total=%d len=%d", total, len(page))
}
if page[0].ID != "al1" || page[1].ID != "al2" {
t.Fatalf("ids = %q/%q", page[0].ID, page[1].ID)
}
}
func TestApplyAlbumListSortBySizeAsc(t *testing.T) {
params := query.ListParams{Page: 1, PageSize: 10, Sort: "size"}
page, _ := applyAlbumList(sampleAlbums(), params)
want := []string{"al1", "al3", "al2"}
for i, id := range want {
if page[i].ID != id {
t.Fatalf("page[%d].ID = %q, want %q", i, page[i].ID, id)
}
}
}
func TestApplyAlbumListFilterByQAndSortCreatedAtDesc(t *testing.T) {
params := query.ListParams{Page: 1, PageSize: 10, Q: "album", Sort: "-created_at"}
page, total := applyAlbumList(sampleAlbums(), params)
if total != 3 {
t.Fatalf("total = %d, want 3", total)
}
if page[0].ID != "al3" {
t.Fatalf("first id = %q, want al3", page[0].ID)
}
}
func TestApplyAlbumListFilterByFromDate(t *testing.T) {
params := query.ListParams{
Page: 1,
PageSize: 10,
From: mustDate(t, "2026-01-15"),
Sort: "name",
}
page, total := applyAlbumList(sampleAlbums(), params)
if total != 2 {
t.Fatalf("total = %d, want 2", total)
}
if page[0].ID != "al2" || page[1].ID != "al3" {
t.Fatalf("ids = %q/%q", page[0].ID, page[1].ID)
}
}
func TestServiceListAssetsUsesPaginationMeta(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/assets" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(sampleAssets())
}))
defer server.Close()
svc := NewService(photospkg.NewClient(server.URL), nil)
result, err := svc.ListAssets(context.Background(), "key", query.ListParams{
Page: 2, PageSize: 2, Sort: "name",
})
if err != nil {
t.Fatalf("ListAssets: %v", err)
}
if len(result.Assets) != 2 || result.Page != 2 {
t.Fatalf("assets=%d page=%d", len(result.Assets), result.Page)
}
if result.Pagination.Page != 2 || result.Pagination.PageSize != 2 {
t.Fatalf("pagination = %+v", result.Pagination)
}
if result.Pagination.Total == nil || *result.Pagination.Total != 4 {
t.Fatalf("total = %v", result.Pagination.Total)
}
}
func TestServiceListAlbumsUsesPaginationMeta(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/albums" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(sampleAlbums())
}))
defer server.Close()
svc := NewService(photospkg.NewClient(server.URL), nil)
result, err := svc.ListAlbums(context.Background(), "key", query.ListParams{
Page: 1, PageSize: 1, Sort: "-size",
})
if err != nil {
t.Fatalf("ListAlbums: %v", err)
}
if len(result.Albums) != 1 || result.Albums[0].ID != "al2" {
t.Fatalf("albums = %+v", result.Albums)
}
if result.Pagination.Total == nil || *result.Pagination.Total != 3 {
t.Fatalf("total = %v", result.Pagination.Total)
}
}
func TestServiceGetAlbumNotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
defer server.Close()
svc := NewService(photospkg.NewClient(server.URL), nil)
_, err := svc.GetAlbum(context.Background(), "key", "missing")
if err == nil || !errors.Is(err, ErrNotFound) {
t.Fatalf("GetAlbum err = %v, want ErrNotFound", err)
}
}
func TestServiceCreateAlbum(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/albums" {
http.NotFound(w, r)
return
}
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(photospkg.Album{ID: "new-al", Name: "Created", AssetCount: 1})
}))
defer server.Close()
svc := NewService(photospkg.NewClient(server.URL), nil)
album, err := svc.CreateAlbum(context.Background(), "key", &createAlbumRequest{
Name: "Created",
AssetIDs: []string{"a1"},
})
if err != nil {
t.Fatalf("CreateAlbum: %v", err)
}
if album.ID != "new-al" || album.Name != "Created" {
t.Fatalf("album = %+v", album)
}
}
func TestServiceUpdateAlbumInvalid(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
}))
defer server.Close()
svc := NewService(photospkg.NewClient(server.URL), nil)
_, err := svc.UpdateAlbum(context.Background(), "key", "al1", &updateAlbumRequest{Name: strPtr("Renamed")})
if err == nil || !errors.Is(err, ErrInvalid) {
t.Fatalf("UpdateAlbum err = %v, want ErrInvalid", err)
}
}
func TestServiceDeleteAlbumNotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
defer server.Close()
svc := NewService(photospkg.NewClient(server.URL), nil)
err := svc.DeleteAlbum(context.Background(), "key", "missing")
if err == nil || !errors.Is(err, ErrNotFound) {
t.Fatalf("DeleteAlbum err = %v, want ErrNotFound", err)
}
}
func TestServiceAddAlbumAssets(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut || r.URL.Path != "/albums/al1/assets" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode([]photospkg.BulkAssetResult{{ID: "a1", Success: true}})
}))
defer server.Close()
svc := NewService(photospkg.NewClient(server.URL), nil)
result, err := svc.AddAlbumAssets(context.Background(), "key", "al1", []string{"a1"})
if err != nil {
t.Fatalf("AddAlbumAssets: %v", err)
}
if len(result.Results) != 1 || !result.Results[0].Success {
t.Fatalf("results = %+v", result.Results)
}
}
func strPtr(value string) *string {
return &value
}
type quotaStub struct {
quota nextcloud.UserQuota
err error
}
func (q quotaStub) GetQuota(context.Context, string) (nextcloud.UserQuota, error) {
return q.quota, q.err
}
func TestServiceUploadAssetQuotaExceeded(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("upload should not be called when quota exceeded")
}))
defer server.Close()
svc := NewService(photospkg.NewClient(server.URL), quotaStub{
quota: nextcloud.UserQuota{Free: 10},
})
ctx := context.WithValue(context.Background(), contentLengthContextKey{}, int64(100))
_, err := svc.UploadAsset(ctx, "user-1", strings.NewReader("payload"), "application/octet-stream")
if err == nil || !errors.Is(err, ErrQuotaExceeded) {
t.Fatalf("UploadAsset err = %v, want ErrQuotaExceeded", err)
}
}

View File

@ -6,6 +6,23 @@ import (
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
)
const maxJSONRequestBody = 32 << 10
type createAlbumRequest struct {
Name string `json:"name"`
Description string `json:"description"`
AssetIDs []string `json:"asset_ids"`
}
type updateAlbumRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
}
type albumAssetsRequest struct {
AssetIDs []string `json:"asset_ids"`
}
func validateAssetID(assetID string) *apivalidate.ValidationError {
if strings.TrimSpace(assetID) == "" {
return apivalidate.NewValidationError(apivalidate.FieldDetail{
@ -14,3 +31,93 @@ func validateAssetID(assetID string) *apivalidate.ValidationError {
}
return nil
}
func validateAlbumID(albumID string) *apivalidate.ValidationError {
if strings.TrimSpace(albumID) == "" {
return apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "album_id", Message: "required",
})
}
return nil
}
func validateAlbumName(field, name string, required bool) *apivalidate.FieldDetail {
name = strings.TrimSpace(name)
if name == "" {
if required {
return &apivalidate.FieldDetail{Field: field, Message: "required"}
}
return nil
}
if strings.Contains(name, "\n") || strings.Contains(name, "\r") {
return &apivalidate.FieldDetail{Field: field, Message: "invalid"}
}
return nil
}
func validateAssetIDs(field string, assetIDs []string, required bool) []apivalidate.FieldDetail {
if len(assetIDs) == 0 {
if required {
return []apivalidate.FieldDetail{{Field: field, Message: "required"}}
}
return nil
}
var details []apivalidate.FieldDetail
for i, id := range assetIDs {
if strings.TrimSpace(id) == "" {
details = append(details, apivalidate.FieldDetail{
Field: field, Message: "invalid",
})
if i == 0 {
break
}
}
}
return details
}
func validateCreateAlbum(req *createAlbumRequest) *apivalidate.ValidationError {
var details []apivalidate.FieldDetail
if d := validateAlbumName("name", req.Name, true); d != nil {
details = append(details, *d)
}
if d := validateAlbumName("description", req.Description, false); d != nil {
details = append(details, *d)
}
details = append(details, validateAssetIDs("asset_ids", req.AssetIDs, false)...)
if len(details) == 0 {
return nil
}
return apivalidate.NewValidationError(details...)
}
func validateUpdateAlbum(req *updateAlbumRequest) *apivalidate.ValidationError {
if req.Name == nil && req.Description == nil {
return apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "body", Message: "required",
})
}
var details []apivalidate.FieldDetail
if req.Name != nil {
if d := validateAlbumName("name", *req.Name, true); d != nil {
details = append(details, *d)
}
}
if req.Description != nil {
if d := validateAlbumName("description", *req.Description, false); d != nil {
details = append(details, *d)
}
}
if len(details) == 0 {
return nil
}
return apivalidate.NewValidationError(details...)
}
func validateAlbumAssetsRequest(req *albumAssetsRequest) *apivalidate.ValidationError {
details := validateAssetIDs("asset_ids", req.AssetIDs, true)
if len(details) == 0 {
return nil
}
return apivalidate.NewValidationError(details...)
}

View File

@ -0,0 +1,73 @@
package photos
import (
"testing"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
)
func hasFieldDetail(err *apivalidate.ValidationError, field, message string) bool {
if err == nil {
return false
}
for _, d := range err.Details {
if d.Field == field && d.Message == message {
return true
}
}
return false
}
func TestValidateAlbumID(t *testing.T) {
if validateAlbumID("album-1") != nil {
t.Fatal("expected valid album id")
}
if err := validateAlbumID(""); !hasFieldDetail(err, "album_id", "required") {
t.Fatal("expected required album_id error")
}
}
func TestValidateCreateAlbum(t *testing.T) {
if validateCreateAlbum(&createAlbumRequest{Name: "Vacation"}) != nil {
t.Fatal("expected valid create album request")
}
if err := validateCreateAlbum(&createAlbumRequest{}); !hasFieldDetail(err, "name", "required") {
t.Fatal("expected missing name error")
}
if err := validateCreateAlbum(&createAlbumRequest{Name: "Trip", AssetIDs: []string{""}}); !hasFieldDetail(err, "asset_ids", "invalid") {
t.Fatal("expected invalid asset_ids error")
}
if err := validateCreateAlbum(&createAlbumRequest{Name: "Bad\nName"}); !hasFieldDetail(err, "name", "invalid") {
t.Fatal("expected invalid name error")
}
}
func TestValidateUpdateAlbum(t *testing.T) {
name := "Renamed"
if validateUpdateAlbum(&updateAlbumRequest{Name: &name}) != nil {
t.Fatal("expected valid update album request")
}
desc := "Notes"
if validateUpdateAlbum(&updateAlbumRequest{Description: &desc}) != nil {
t.Fatal("expected valid description-only update")
}
if err := validateUpdateAlbum(&updateAlbumRequest{}); !hasFieldDetail(err, "body", "required") {
t.Fatal("expected empty body error")
}
blank := " "
if err := validateUpdateAlbum(&updateAlbumRequest{Name: &blank}); !hasFieldDetail(err, "name", "required") {
t.Fatal("expected blank name error")
}
}
func TestValidateAlbumAssetsRequest(t *testing.T) {
if validateAlbumAssetsRequest(&albumAssetsRequest{AssetIDs: []string{"a1"}}) != nil {
t.Fatal("expected valid album assets request")
}
if err := validateAlbumAssetsRequest(&albumAssetsRequest{}); !hasFieldDetail(err, "asset_ids", "required") {
t.Fatal("expected required asset_ids error")
}
if err := validateAlbumAssetsRequest(&albumAssetsRequest{AssetIDs: []string{" "}}); !hasFieldDetail(err, "asset_ids", "invalid") {
t.Fatal("expected invalid asset_ids error")
}
}

129
internal/photos/albums.go Normal file
View File

@ -0,0 +1,129 @@
package photos
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
type BulkAssetResult struct {
ID string `json:"id"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
type createAlbumPayload struct {
AlbumName string `json:"albumName"`
Description string `json:"description,omitempty"`
AssetIDs []string `json:"assetIds,omitempty"`
}
type updateAlbumPayload struct {
AlbumName *string `json:"albumName,omitempty"`
Description *string `json:"description,omitempty"`
}
type bulkIDsPayload struct {
IDs []string `json:"ids"`
}
func (c *Client) GetAlbum(ctx context.Context, apiKey, albumID string) (Album, error) {
url := fmt.Sprintf("%s/albums/%s?withoutAssets=true", c.baseURL, albumID)
var album Album
if err := c.doJSON(ctx, http.MethodGet, url, apiKey, nil, &album, "get album", http.StatusOK); err != nil {
return Album{}, err
}
return album, nil
}
func (c *Client) CreateAlbum(ctx context.Context, apiKey, name, description string, assetIDs []string) (Album, error) {
payload := createAlbumPayload{
AlbumName: name,
Description: description,
AssetIDs: assetIDs,
}
var album Album
if err := c.doJSON(ctx, http.MethodPost, c.baseURL+"/albums", apiKey, payload, &album, "create album", http.StatusCreated); err != nil {
return Album{}, err
}
return album, nil
}
func (c *Client) UpdateAlbum(ctx context.Context, apiKey, albumID string, name, description *string) (Album, error) {
payload := updateAlbumPayload{
AlbumName: name,
Description: description,
}
url := fmt.Sprintf("%s/albums/%s", c.baseURL, albumID)
var album Album
if err := c.doJSON(ctx, http.MethodPatch, url, apiKey, payload, &album, "update album", http.StatusOK); err != nil {
return Album{}, err
}
return album, nil
}
func (c *Client) DeleteAlbum(ctx context.Context, apiKey, albumID string) error {
url := fmt.Sprintf("%s/albums/%s", c.baseURL, albumID)
return c.doJSON(ctx, http.MethodDelete, url, apiKey, nil, nil, "delete album", http.StatusOK, http.StatusNoContent)
}
func (c *Client) AddAlbumAssets(ctx context.Context, apiKey, albumID string, assetIDs []string) ([]BulkAssetResult, error) {
url := fmt.Sprintf("%s/albums/%s/assets", c.baseURL, albumID)
var results []BulkAssetResult
if err := c.doJSON(ctx, http.MethodPut, url, apiKey, bulkIDsPayload{IDs: assetIDs}, &results, "add album assets", http.StatusOK); err != nil {
return nil, err
}
return results, nil
}
func (c *Client) RemoveAlbumAssets(ctx context.Context, apiKey, albumID string, assetIDs []string) ([]BulkAssetResult, error) {
url := fmt.Sprintf("%s/albums/%s/assets", c.baseURL, albumID)
var results []BulkAssetResult
if err := c.doJSON(ctx, http.MethodDelete, url, apiKey, bulkIDsPayload{IDs: assetIDs}, &results, "remove album assets", http.StatusOK); err != nil {
return nil, err
}
return results, nil
}
func (c *Client) doJSON(ctx context.Context, method, url, apiKey string, reqBody any, dest any, operation string, okStatuses ...int) error {
var body io.Reader
if reqBody != nil {
data, err := json.Marshal(reqBody)
if err != nil {
return err
}
body = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return err
}
req.Header.Set("x-api-key", apiKey)
if reqBody != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
for _, status := range okStatuses {
if resp.StatusCode == status {
if dest != nil {
if err := json.NewDecoder(resp.Body).Decode(dest); err != nil {
return err
}
}
return nil
}
}
io.Copy(io.Discard, resp.Body)
return &HTTPStatusError{Operation: operation, StatusCode: resp.StatusCode}
}

View File

@ -1,6 +1,7 @@
package photos
import (
"bytes"
"context"
"encoding/json"
"fmt"
@ -29,22 +30,49 @@ type Asset struct {
OriginalPath string `json:"originalPath"`
OriginalName string `json:"originalFileName"`
MimeType string `json:"originalMimeType"`
FileSize int64 `json:"exifInfo.fileSizeInByte"`
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, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
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)
@ -52,15 +80,52 @@ func (c *Client) GetAssets(ctx context.Context, apiKey string, page, size int) (
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
json.NewDecoder(resp.Body).Decode(&assets)
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, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
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)
@ -68,15 +133,24 @@ func (c *Client) GetAlbums(ctx context.Context, apiKey string) ([]Album, error)
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
json.NewDecoder(resp.Body).Decode(&albums)
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, _ := http.NewRequestWithContext(ctx, "POST", url, body)
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)
@ -85,36 +159,61 @@ func (c *Client) UploadAsset(ctx context.Context, apiKey string, body io.Reader,
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"`
}
json.NewDecoder(resp.Body).Decode(&result)
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, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
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, _ := http.NewRequestWithContext(ctx, "DELETE", url, nil)
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
}
resp.Body.Close()
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
}

View File

@ -0,0 +1,200 @@
package photos
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestGetAssetsSupportsWrappedItemsResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/assets" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"items": []map[string]any{
{
"id": "a1",
"originalFileName": "a.jpg",
"fileCreatedAt": "2026-01-01T00:00:00Z",
"exifInfo": map[string]any{
"fileSizeInByte": int64(42),
},
},
},
})
}))
defer server.Close()
client := NewClient(server.URL)
assets, err := client.GetAssets(context.Background(), "key", 1, 50)
if err != nil {
t.Fatalf("GetAssets: %v", err)
}
if len(assets) != 1 || assets[0].FileSize != 42 {
t.Fatalf("assets = %+v", assets)
}
}
func TestGetAllAssetsPaginatesUpstream(t *testing.T) {
call := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
call++
page := r.URL.Query().Get("page")
if page == "1" {
batch := make([]Asset, assetsPageSize)
for i := range batch {
batch[i] = Asset{ID: fmt.Sprintf("full-%d", i)}
}
_ = json.NewEncoder(w).Encode(batch)
return
}
if page == "2" {
_ = json.NewEncoder(w).Encode([]Asset{
{ID: "tail-1"},
{ID: "tail-2"},
})
return
}
_ = json.NewEncoder(w).Encode([]Asset{})
}))
defer server.Close()
client := NewClient(server.URL)
assets, err := client.GetAllAssets(context.Background(), "key")
if err != nil {
t.Fatalf("GetAllAssets: %v", err)
}
if len(assets) != assetsPageSize+2 {
t.Fatalf("len = %d, want %d", len(assets), assetsPageSize+2)
}
if call != 2 {
t.Fatalf("upstream calls = %d, want 2", call)
}
}
func TestCreateAlbumMapsImmichPayload(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/albums" {
http.NotFound(w, r)
return
}
if r.Header.Get("x-api-key") != "key" {
t.Fatalf("api key = %q", r.Header.Get("x-api-key"))
}
var payload createAlbumPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
if payload.AlbumName != "Trip" || payload.Description != "Summer" || len(payload.AssetIDs) != 1 || payload.AssetIDs[0] != "asset-1" {
t.Fatalf("payload = %+v", payload)
}
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(Album{ID: "album-1", Name: payload.AlbumName})
}))
defer server.Close()
client := NewClient(server.URL)
album, err := client.CreateAlbum(context.Background(), "key", "Trip", "Summer", []string{"asset-1"})
if err != nil {
t.Fatalf("CreateAlbum: %v", err)
}
if album.ID != "album-1" || album.Name != "Trip" {
t.Fatalf("album = %+v", album)
}
}
func TestUploadAssetStatusMapped(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
}))
defer server.Close()
client := NewClient(server.URL)
_, err := client.UploadAsset(context.Background(), "key", strings.NewReader("body"), "application/octet-stream")
if err == nil {
t.Fatal("expected error")
}
var statusErr *HTTPStatusError
if !errors.As(err, &statusErr) || statusErr.StatusCode != http.StatusBadRequest {
t.Fatalf("err = %v", err)
}
}
func TestGetAlbumNotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
defer server.Close()
client := NewClient(server.URL)
_, err := client.GetAlbum(context.Background(), "key", "missing")
if err == nil {
t.Fatal("expected error")
}
var statusErr *HTTPStatusError
if !errors.As(err, &statusErr) || statusErr.StatusCode != http.StatusNotFound {
t.Fatalf("err = %v", err)
}
}
func TestAddAlbumAssetsSendsBulkIDs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut || r.URL.Path != "/albums/album-1/assets" {
http.NotFound(w, r)
return
}
var payload bulkIDsPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
if len(payload.IDs) != 2 || payload.IDs[0] != "a1" || payload.IDs[1] != "a2" {
t.Fatalf("payload = %+v", payload)
}
_ = json.NewEncoder(w).Encode([]BulkAssetResult{{ID: "a1", Success: true}, {ID: "a2", Success: true}})
}))
defer server.Close()
client := NewClient(server.URL)
results, err := client.AddAlbumAssets(context.Background(), "key", "album-1", []string{"a1", "a2"})
if err != nil {
t.Fatalf("AddAlbumAssets: %v", err)
}
if len(results) != 2 {
t.Fatalf("results = %+v", results)
}
}
func TestRemoveAlbumAssetsUsesDeleteWithBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete || r.URL.Path != "/albums/album-1/assets" {
http.NotFound(w, r)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if string(body) != `{"ids":["a1"]}` {
t.Fatalf("body = %s", string(body))
}
_ = json.NewEncoder(w).Encode([]BulkAssetResult{{ID: "a1", Success: true}})
}))
defer server.Close()
client := NewClient(server.URL)
results, err := client.RemoveAlbumAssets(context.Background(), "key", "album-1", []string{"a1"})
if err != nil {
t.Fatalf("RemoveAlbumAssets: %v", err)
}
if len(results) != 1 || !results[0].Success {
t.Fatalf("results = %+v", results)
}
}

12
internal/photos/errors.go Normal file
View File

@ -0,0 +1,12 @@
package photos
import "fmt"
type HTTPStatusError struct {
Operation string
StatusCode int
}
func (e *HTTPStatusError) Error() string {
return fmt.Sprintf("%s failed: %d", e.Operation, e.StatusCode)
}

View File

@ -166,10 +166,10 @@ Objectif: transformer état actuel (partiellement implémenté) vers produit fon
#### Photos
- [ ] Corriger mapping API Immich (champs JSON exacts, erreurs HTTP).
- [ ] Ajouter pagination/tri/filtres robustes.
- [ ] Ajouter albums CRUD complets.
- [ ] Ajouter liaison stockage/quota avec Ultidrive.
- [x] Corriger mapping API Immich (champs JSON exacts, erreurs HTTP).
- [x] Ajouter pagination/tri/filtres robustes.
- [x] Ajouter albums CRUD complets.
- [x] Ajouter liaison stockage/quota avec Ultidrive.
#### Admin