service.go 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. package albums
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "io"
  7. "os"
  8. "path/filepath"
  9. "sort"
  10. "strings"
  11. "sync"
  12. "github.com/google/uuid"
  13. cfgpkg "viewer/internal/config"
  14. "viewer/internal/models"
  15. "viewer/internal/storage"
  16. )
  17. type Service struct {
  18. cfg cfgpkg.Config
  19. store *storage.S3Store
  20. indexer *Indexer
  21. mu sync.RWMutex
  22. albumCache map[string]*models.AlbumIndex
  23. uploadHints map[string]string
  24. }
  25. func NewService(cfg cfgpkg.Config, store *storage.S3Store, indexer *Indexer) *Service {
  26. return &Service{
  27. cfg: cfg,
  28. store: store,
  29. indexer: indexer,
  30. albumCache: make(map[string]*models.AlbumIndex),
  31. uploadHints: make(map[string]string),
  32. }
  33. }
  34. type CreateUploadResult struct {
  35. AlbumID string
  36. URL string
  37. Headers map[string]string
  38. }
  39. func sourceKey(albumID string) string {
  40. return fmt.Sprintf("albums/%s/source.zip", albumID)
  41. }
  42. func indexKey(albumID string) string {
  43. return fmt.Sprintf("albums/%s/index.json", albumID)
  44. }
  45. func (s *Service) CreateUpload(ctx context.Context, filename string, sizeBytes int64) (CreateUploadResult, error) {
  46. if strings.TrimSpace(filename) == "" {
  47. return CreateUploadResult{}, fmt.Errorf("filename is required")
  48. }
  49. if sizeBytes <= 0 {
  50. return CreateUploadResult{}, fmt.Errorf("sizeBytes must be > 0")
  51. }
  52. if sizeBytes > s.cfg.MaxUploadBytes {
  53. return CreateUploadResult{}, fmt.Errorf("sizeBytes exceeds MAX_UPLOAD_BYTES")
  54. }
  55. albumID := uuid.NewString()
  56. url, headers, err := s.store.PresignPut(ctx, sourceKey(albumID), s.cfg.PresignTTL)
  57. if err != nil {
  58. return CreateUploadResult{}, err
  59. }
  60. s.mu.Lock()
  61. s.uploadHints[albumID] = filename
  62. s.mu.Unlock()
  63. return CreateUploadResult{
  64. AlbumID: albumID,
  65. URL: url,
  66. Headers: headers,
  67. }, nil
  68. }
  69. func (s *Service) UploadSource(ctx context.Context, albumID string, filename string, reader io.Reader) error {
  70. if strings.TrimSpace(albumID) == "" {
  71. return fmt.Errorf("albumId is required")
  72. }
  73. if reader == nil {
  74. return fmt.Errorf("file is required")
  75. }
  76. if err := s.store.PutObject(ctx, sourceKey(albumID), reader, "application/zip"); err != nil {
  77. return err
  78. }
  79. if strings.TrimSpace(filename) != "" {
  80. s.mu.Lock()
  81. s.uploadHints[albumID] = filename
  82. s.mu.Unlock()
  83. }
  84. return nil
  85. }
  86. func (s *Service) Finalize(ctx context.Context, albumID string) (*models.AlbumIndex, error) {
  87. if strings.TrimSpace(albumID) == "" {
  88. return nil, fmt.Errorf("albumId is required")
  89. }
  90. exists, _, err := s.store.HeadObject(ctx, sourceKey(albumID))
  91. if err != nil {
  92. return nil, err
  93. }
  94. if !exists {
  95. return nil, fmt.Errorf("source zip not found")
  96. }
  97. body, _, err := s.store.GetObject(ctx, sourceKey(albumID))
  98. if err != nil {
  99. return nil, err
  100. }
  101. defer body.Close()
  102. tmpFile, err := os.CreateTemp("", "viewer-album-*.zip")
  103. if err != nil {
  104. return nil, fmt.Errorf("create temp file: %w", err)
  105. }
  106. tmpPath := tmpFile.Name()
  107. defer os.Remove(tmpPath)
  108. if _, err := io.Copy(tmpFile, body); err != nil {
  109. tmpFile.Close()
  110. return nil, fmt.Errorf("download zip: %w", err)
  111. }
  112. if err := tmpFile.Close(); err != nil {
  113. return nil, fmt.Errorf("close temp file: %w", err)
  114. }
  115. originalFilename := "source.zip"
  116. s.mu.RLock()
  117. if hinted, ok := s.uploadHints[albumID]; ok && hinted != "" {
  118. originalFilename = hinted
  119. }
  120. s.mu.RUnlock()
  121. idx, err := s.indexer.BuildFromZip(tmpPath, albumID, originalFilename)
  122. if err != nil {
  123. return nil, err
  124. }
  125. if err := s.store.PutJSON(ctx, indexKey(albumID), idx); err != nil {
  126. return nil, err
  127. }
  128. s.mu.Lock()
  129. s.albumCache[albumID] = idx
  130. delete(s.uploadHints, albumID)
  131. s.mu.Unlock()
  132. return idx, nil
  133. }
  134. func (s *Service) RefreshFromStorage(ctx context.Context) error {
  135. keys, err := s.store.ListAlbumIndexKeys(ctx)
  136. if err != nil {
  137. return err
  138. }
  139. next := make(map[string]*models.AlbumIndex, len(keys))
  140. for _, key := range keys {
  141. var idx models.AlbumIndex
  142. if err := s.store.ReadJSON(ctx, key, &idx); err != nil {
  143. continue
  144. }
  145. if idx.AlbumID == "" {
  146. parts := strings.Split(filepath.Dir(key), "/")
  147. if len(parts) > 1 {
  148. idx.AlbumID = parts[len(parts)-1]
  149. }
  150. }
  151. next[idx.AlbumID] = &idx
  152. }
  153. s.mu.Lock()
  154. s.albumCache = next
  155. s.mu.Unlock()
  156. return nil
  157. }
  158. func (s *Service) GetAlbum(ctx context.Context, albumID string) (*models.AlbumIndex, error) {
  159. s.mu.RLock()
  160. if idx, ok := s.albumCache[albumID]; ok {
  161. dup := *idx
  162. s.mu.RUnlock()
  163. return &dup, nil
  164. }
  165. s.mu.RUnlock()
  166. var idx models.AlbumIndex
  167. if err := s.store.ReadJSON(ctx, indexKey(albumID), &idx); err != nil {
  168. return nil, err
  169. }
  170. if idx.AlbumID == "" {
  171. idx.AlbumID = albumID
  172. }
  173. s.mu.Lock()
  174. s.albumCache[albumID] = &idx
  175. s.mu.Unlock()
  176. return &idx, nil
  177. }
  178. func (s *Service) ListAlbums(ctx context.Context) ([]models.AlbumSummary, error) {
  179. s.mu.RLock()
  180. if len(s.albumCache) == 0 {
  181. s.mu.RUnlock()
  182. if err := s.RefreshFromStorage(ctx); err != nil {
  183. return nil, err
  184. }
  185. s.mu.RLock()
  186. }
  187. out := make([]models.AlbumSummary, 0, len(s.albumCache))
  188. for _, idx := range s.albumCache {
  189. out = append(out, models.AlbumSummary{
  190. AlbumID: idx.AlbumID,
  191. OriginalFilename: idx.OriginalFilename,
  192. PhotoCount: idx.PhotoCount,
  193. CreatedAt: idx.CreatedAt,
  194. })
  195. }
  196. s.mu.RUnlock()
  197. sort.Slice(out, func(i, j int) bool {
  198. return out[i].CreatedAt > out[j].CreatedAt
  199. })
  200. return out, nil
  201. }
  202. func (s *Service) AllAlbums() []*models.AlbumIndex {
  203. s.mu.RLock()
  204. defer s.mu.RUnlock()
  205. out := make([]*models.AlbumIndex, 0, len(s.albumCache))
  206. for _, idx := range s.albumCache {
  207. dup := *idx
  208. dup.Photos = append([]models.PhotoMeta(nil), idx.Photos...)
  209. out = append(out, &dup)
  210. }
  211. return out
  212. }
  213. func (s *Service) DumpAlbumJSON(albumID string) ([]byte, error) {
  214. s.mu.RLock()
  215. idx, ok := s.albumCache[albumID]
  216. s.mu.RUnlock()
  217. if !ok {
  218. return nil, fmt.Errorf("album not cached")
  219. }
  220. return json.Marshal(idx)
  221. }