| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258 |
- package albums
- import (
- "context"
- "encoding/json"
- "fmt"
- "io"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "sync"
- "github.com/google/uuid"
- cfgpkg "viewer/internal/config"
- "viewer/internal/models"
- "viewer/internal/storage"
- )
- type Service struct {
- cfg cfgpkg.Config
- store *storage.S3Store
- indexer *Indexer
- mu sync.RWMutex
- albumCache map[string]*models.AlbumIndex
- uploadHints map[string]string
- }
- func NewService(cfg cfgpkg.Config, store *storage.S3Store, indexer *Indexer) *Service {
- return &Service{
- cfg: cfg,
- store: store,
- indexer: indexer,
- albumCache: make(map[string]*models.AlbumIndex),
- uploadHints: make(map[string]string),
- }
- }
- type CreateUploadResult struct {
- AlbumID string
- URL string
- Headers map[string]string
- }
- func sourceKey(albumID string) string {
- return fmt.Sprintf("albums/%s/source.zip", albumID)
- }
- func indexKey(albumID string) string {
- return fmt.Sprintf("albums/%s/index.json", albumID)
- }
- func (s *Service) CreateUpload(ctx context.Context, filename string, sizeBytes int64) (CreateUploadResult, error) {
- if strings.TrimSpace(filename) == "" {
- return CreateUploadResult{}, fmt.Errorf("filename is required")
- }
- if sizeBytes <= 0 {
- return CreateUploadResult{}, fmt.Errorf("sizeBytes must be > 0")
- }
- if sizeBytes > s.cfg.MaxUploadBytes {
- return CreateUploadResult{}, fmt.Errorf("sizeBytes exceeds MAX_UPLOAD_BYTES")
- }
- albumID := uuid.NewString()
- url, headers, err := s.store.PresignPut(ctx, sourceKey(albumID), s.cfg.PresignTTL)
- if err != nil {
- return CreateUploadResult{}, err
- }
- s.mu.Lock()
- s.uploadHints[albumID] = filename
- s.mu.Unlock()
- return CreateUploadResult{
- AlbumID: albumID,
- URL: url,
- Headers: headers,
- }, nil
- }
- func (s *Service) UploadSource(ctx context.Context, albumID string, filename string, reader io.Reader) error {
- if strings.TrimSpace(albumID) == "" {
- return fmt.Errorf("albumId is required")
- }
- if reader == nil {
- return fmt.Errorf("file is required")
- }
- if err := s.store.PutObject(ctx, sourceKey(albumID), reader, "application/zip"); err != nil {
- return err
- }
- if strings.TrimSpace(filename) != "" {
- s.mu.Lock()
- s.uploadHints[albumID] = filename
- s.mu.Unlock()
- }
- return nil
- }
- func (s *Service) Finalize(ctx context.Context, albumID string) (*models.AlbumIndex, error) {
- if strings.TrimSpace(albumID) == "" {
- return nil, fmt.Errorf("albumId is required")
- }
- exists, _, err := s.store.HeadObject(ctx, sourceKey(albumID))
- if err != nil {
- return nil, err
- }
- if !exists {
- return nil, fmt.Errorf("source zip not found")
- }
- body, _, err := s.store.GetObject(ctx, sourceKey(albumID))
- if err != nil {
- return nil, err
- }
- defer body.Close()
- tmpFile, err := os.CreateTemp("", "viewer-album-*.zip")
- if err != nil {
- return nil, fmt.Errorf("create temp file: %w", err)
- }
- tmpPath := tmpFile.Name()
- defer os.Remove(tmpPath)
- if _, err := io.Copy(tmpFile, body); err != nil {
- tmpFile.Close()
- return nil, fmt.Errorf("download zip: %w", err)
- }
- if err := tmpFile.Close(); err != nil {
- return nil, fmt.Errorf("close temp file: %w", err)
- }
- originalFilename := "source.zip"
- s.mu.RLock()
- if hinted, ok := s.uploadHints[albumID]; ok && hinted != "" {
- originalFilename = hinted
- }
- s.mu.RUnlock()
- idx, err := s.indexer.BuildFromZip(tmpPath, albumID, originalFilename)
- if err != nil {
- return nil, err
- }
- if err := s.store.PutJSON(ctx, indexKey(albumID), idx); err != nil {
- return nil, err
- }
- s.mu.Lock()
- s.albumCache[albumID] = idx
- delete(s.uploadHints, albumID)
- s.mu.Unlock()
- return idx, nil
- }
- func (s *Service) RefreshFromStorage(ctx context.Context) error {
- keys, err := s.store.ListAlbumIndexKeys(ctx)
- if err != nil {
- return err
- }
- next := make(map[string]*models.AlbumIndex, len(keys))
- for _, key := range keys {
- var idx models.AlbumIndex
- if err := s.store.ReadJSON(ctx, key, &idx); err != nil {
- continue
- }
- if idx.AlbumID == "" {
- parts := strings.Split(filepath.Dir(key), "/")
- if len(parts) > 1 {
- idx.AlbumID = parts[len(parts)-1]
- }
- }
- next[idx.AlbumID] = &idx
- }
- s.mu.Lock()
- s.albumCache = next
- s.mu.Unlock()
- return nil
- }
- func (s *Service) GetAlbum(ctx context.Context, albumID string) (*models.AlbumIndex, error) {
- s.mu.RLock()
- if idx, ok := s.albumCache[albumID]; ok {
- dup := *idx
- s.mu.RUnlock()
- return &dup, nil
- }
- s.mu.RUnlock()
- var idx models.AlbumIndex
- if err := s.store.ReadJSON(ctx, indexKey(albumID), &idx); err != nil {
- return nil, err
- }
- if idx.AlbumID == "" {
- idx.AlbumID = albumID
- }
- s.mu.Lock()
- s.albumCache[albumID] = &idx
- s.mu.Unlock()
- return &idx, nil
- }
- func (s *Service) ListAlbums(ctx context.Context) ([]models.AlbumSummary, error) {
- s.mu.RLock()
- if len(s.albumCache) == 0 {
- s.mu.RUnlock()
- if err := s.RefreshFromStorage(ctx); err != nil {
- return nil, err
- }
- s.mu.RLock()
- }
- out := make([]models.AlbumSummary, 0, len(s.albumCache))
- for _, idx := range s.albumCache {
- out = append(out, models.AlbumSummary{
- AlbumID: idx.AlbumID,
- OriginalFilename: idx.OriginalFilename,
- PhotoCount: idx.PhotoCount,
- CreatedAt: idx.CreatedAt,
- })
- }
- s.mu.RUnlock()
- sort.Slice(out, func(i, j int) bool {
- return out[i].CreatedAt > out[j].CreatedAt
- })
- return out, nil
- }
- func (s *Service) AllAlbums() []*models.AlbumIndex {
- s.mu.RLock()
- defer s.mu.RUnlock()
- out := make([]*models.AlbumIndex, 0, len(s.albumCache))
- for _, idx := range s.albumCache {
- dup := *idx
- dup.Photos = append([]models.PhotoMeta(nil), idx.Photos...)
- out = append(out, &dup)
- }
- return out
- }
- func (s *Service) DumpAlbumJSON(albumID string) ([]byte, error) {
- s.mu.RLock()
- idx, ok := s.albumCache[albumID]
- s.mu.RUnlock()
- if !ok {
- return nil, fmt.Errorf("album not cached")
- }
- return json.Marshal(idx)
- }
|