ultisuite-backend/internal/api/photos/service.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

222 lines
6.1 KiB
Go

package photos
import (
"context"
"errors"
"io"
"net/http"
"os"
"strconv"
"strings"
"github.com/ultisuite/ulti-backend/internal/api/query"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
photospkg "github.com/ultisuite/ulti-backend/internal/photos"
)
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)
}
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 {
Assets []photospkg.Asset `json:"assets"`
Page int `json:"page"`
Pagination query.PaginationMeta `json:"pagination,omitempty"`
}
func (s *Service) ListAssets(ctx context.Context, apiKey string, params query.ListParams) (AssetsList, error) {
assets, err := s.client.GetAllAssets(ctx, apiKey)
if err != nil {
return AssetsList{}, mapPhotosError(err)
}
page, total := applyAssetList(assets, params)
return AssetsList{
Assets: page,
Page: params.Page,
Pagination: params.Meta(&total),
}, nil
}
type AlbumsList struct {
Albums []photospkg.Album `json:"albums"`
Pagination query.PaginationMeta `json:"pagination,omitempty"`
}
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{}, mapPhotosError(err)
}
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) {
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) {
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 mapPhotosError(s.client.DeleteAsset(ctx, apiKey, assetID))
}
func (s *Service) ensureQuota(ctx context.Context, userID string) error {
if s.quota == nil {
return nil
}
incomingBytes := requestContentLength(ctx)
if incomingBytes > 0 && s.maxUploadBytes > 0 && incomingBytes > s.maxUploadBytes {
return ErrQuotaExceeded
}
if incomingBytes <= 0 {
return nil
}
quota, err := s.quota.GetQuota(ctx, userID)
if err != nil {
return err
}
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
}