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.
222 lines
6.1 KiB
Go
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
|
|
}
|