service.go 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. package images
  2. import (
  3. "archive/zip"
  4. "bytes"
  5. "context"
  6. "fmt"
  7. "image"
  8. "image/jpeg"
  9. _ "image/jpeg"
  10. _ "image/png"
  11. "io"
  12. "mime"
  13. "os"
  14. "path/filepath"
  15. "strconv"
  16. "strings"
  17. "golang.org/x/image/draw"
  18. _ "golang.org/x/image/webp"
  19. "viewer/internal/albums"
  20. "viewer/internal/cache"
  21. "viewer/internal/storage"
  22. )
  23. type Service struct {
  24. albums *albums.Service
  25. store *storage.S3Store
  26. cache *cache.DiskCache
  27. zipCache string
  28. }
  29. type ImageResult struct {
  30. Bytes []byte
  31. ContentType string
  32. }
  33. func NewService(albumsService *albums.Service, store *storage.S3Store, cacheDir string, zipCacheDir string) (*Service, error) {
  34. dc, err := cache.NewDiskCache(cacheDir)
  35. if err != nil {
  36. return nil, err
  37. }
  38. if err := os.MkdirAll(zipCacheDir, 0o755); err != nil {
  39. return nil, fmt.Errorf("create zip cache dir: %w", err)
  40. }
  41. return &Service{
  42. albums: albumsService,
  43. store: store,
  44. cache: dc,
  45. zipCache: zipCacheDir,
  46. }, nil
  47. }
  48. func sourceKey(albumID string) string {
  49. return fmt.Sprintf("albums/%s/source.zip", albumID)
  50. }
  51. func (s *Service) GetImage(ctx context.Context, albumID string, idx int, mode string, wallWidth int) (ImageResult, error) {
  52. album, err := s.albums.GetAlbum(ctx, albumID)
  53. if err != nil {
  54. return ImageResult{}, err
  55. }
  56. if idx < 0 || idx >= len(album.Photos) {
  57. return ImageResult{}, fmt.Errorf("photo index out of range")
  58. }
  59. photo := album.Photos[idx]
  60. data, contentType, err := s.readEntryBytes(ctx, albumID, photo.Name)
  61. if err != nil {
  62. return ImageResult{}, err
  63. }
  64. if mode != "wall" {
  65. return ImageResult{Bytes: data, ContentType: contentType}, nil
  66. }
  67. if wallWidth <= 0 {
  68. wallWidth = 480
  69. }
  70. if wallWidth < 64 {
  71. wallWidth = 64
  72. }
  73. if wallWidth > 2048 {
  74. wallWidth = 2048
  75. }
  76. cacheKey := strings.Join([]string{albumID, strconv.Itoa(idx), "wall", strconv.Itoa(wallWidth)}, ":")
  77. if cached, ok := s.cache.Get(cacheKey); ok {
  78. return ImageResult{Bytes: cached, ContentType: "image/jpeg"}, nil
  79. }
  80. img, _, err := image.Decode(bytes.NewReader(data))
  81. if err != nil {
  82. return ImageResult{}, fmt.Errorf("decode image: %w", err)
  83. }
  84. b := img.Bounds()
  85. origW := b.Dx()
  86. origH := b.Dy()
  87. if origW <= 0 || origH <= 0 {
  88. return ImageResult{}, fmt.Errorf("invalid image dimensions")
  89. }
  90. targetH := int(float64(origH) * float64(wallWidth) / float64(origW))
  91. if targetH < 1 {
  92. targetH = 1
  93. }
  94. dst := image.NewRGBA(image.Rect(0, 0, wallWidth, targetH))
  95. draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
  96. var out bytes.Buffer
  97. if err := jpeg.Encode(&out, dst, &jpeg.Options{Quality: 82}); err != nil {
  98. return ImageResult{}, fmt.Errorf("encode jpeg: %w", err)
  99. }
  100. encoded := out.Bytes()
  101. _ = s.cache.Set(cacheKey, encoded)
  102. return ImageResult{Bytes: encoded, ContentType: "image/jpeg"}, nil
  103. }
  104. func (s *Service) localZipPath(albumID string) string {
  105. return filepath.Join(s.zipCache, albumID+".zip")
  106. }
  107. func (s *Service) ensureZipCached(ctx context.Context, albumID string) (string, error) {
  108. zipPath := s.localZipPath(albumID)
  109. if _, err := os.Stat(zipPath); err == nil {
  110. return zipPath, nil
  111. }
  112. body, _, err := s.store.GetObject(ctx, sourceKey(albumID))
  113. if err != nil {
  114. return "", err
  115. }
  116. defer body.Close()
  117. tmp, err := os.CreateTemp(s.zipCache, albumID+"-*.zip")
  118. if err != nil {
  119. return "", fmt.Errorf("create temp zip: %w", err)
  120. }
  121. tmpPath := tmp.Name()
  122. if _, err := io.Copy(tmp, body); err != nil {
  123. tmp.Close()
  124. os.Remove(tmpPath)
  125. return "", fmt.Errorf("cache zip: %w", err)
  126. }
  127. if err := tmp.Close(); err != nil {
  128. os.Remove(tmpPath)
  129. return "", err
  130. }
  131. if err := os.Rename(tmpPath, zipPath); err != nil {
  132. if _, stErr := os.Stat(zipPath); stErr == nil {
  133. _ = os.Remove(tmpPath)
  134. return zipPath, nil
  135. }
  136. return "", fmt.Errorf("move cached zip: %w", err)
  137. }
  138. return zipPath, nil
  139. }
  140. func (s *Service) readEntryBytes(ctx context.Context, albumID string, entryName string) ([]byte, string, error) {
  141. zipPath, err := s.ensureZipCached(ctx, albumID)
  142. if err != nil {
  143. return nil, "", err
  144. }
  145. r, err := zip.OpenReader(zipPath)
  146. if err != nil {
  147. return nil, "", fmt.Errorf("open zip cache: %w", err)
  148. }
  149. defer r.Close()
  150. for _, f := range r.File {
  151. if f.Name != entryName {
  152. continue
  153. }
  154. rc, err := f.Open()
  155. if err != nil {
  156. return nil, "", fmt.Errorf("open zip entry: %w", err)
  157. }
  158. data, err := io.ReadAll(rc)
  159. rc.Close()
  160. if err != nil {
  161. return nil, "", fmt.Errorf("read zip entry: %w", err)
  162. }
  163. ct := mime.TypeByExtension(strings.ToLower(filepath.Ext(f.Name)))
  164. if ct == "" {
  165. ct = "application/octet-stream"
  166. }
  167. return data, ct, nil
  168. }
  169. return nil, "", fmt.Errorf("image entry not found")
  170. }