// Package storage provides a local-filesystem implementation of port.FileStorage. package storage import ( "bytes" "context" "fmt" "image" "image/color" "image/jpeg" _ "image/gif" // register GIF decoder _ "image/png" // register PNG decoder _ "golang.org/x/image/webp" // register WebP decoder "io" "os" "os/exec" "path/filepath" "github.com/disintegration/imaging" "github.com/google/uuid" "tanabata/backend/internal/domain" "tanabata/backend/internal/port" ) // DiskStorage implements port.FileStorage using the local filesystem. // // Directory layout: // // {filesPath}/{id} — original file (UUID basename, no extension) // {thumbsPath}/{id}_thumb.jpg — thumbnail cache // {thumbsPath}/{id}_preview.jpg — preview cache type DiskStorage struct { filesPath string thumbsPath string thumbWidth int thumbHeight int previewWidth int previewHeight int } var _ port.FileStorage = (*DiskStorage)(nil) // NewDiskStorage creates a DiskStorage and ensures both directories exist. func NewDiskStorage( filesPath, thumbsPath string, thumbW, thumbH, prevW, prevH int, ) (*DiskStorage, error) { for _, p := range []string{filesPath, thumbsPath} { if err := os.MkdirAll(p, 0o755); err != nil { return nil, fmt.Errorf("storage: create directory %q: %w", p, err) } } return &DiskStorage{ filesPath: filesPath, thumbsPath: thumbsPath, thumbWidth: thumbW, thumbHeight: thumbH, previewWidth: prevW, previewHeight: prevH, }, nil } // --------------------------------------------------------------------------- // port.FileStorage implementation // --------------------------------------------------------------------------- // Save writes r to {filesPath}/{id} and returns the number of bytes written. func (s *DiskStorage) Save(_ context.Context, id uuid.UUID, r io.Reader) (int64, error) { dst := s.originalPath(id) f, err := os.Create(dst) if err != nil { return 0, fmt.Errorf("storage.Save create %q: %w", dst, err) } n, copyErr := io.Copy(f, r) closeErr := f.Close() if copyErr != nil { os.Remove(dst) return 0, fmt.Errorf("storage.Save write: %w", copyErr) } if closeErr != nil { os.Remove(dst) return 0, fmt.Errorf("storage.Save close: %w", closeErr) } return n, nil } // Read opens the original file for reading. The caller must close the result. func (s *DiskStorage) Read(_ context.Context, id uuid.UUID) (io.ReadCloser, error) { f, err := os.Open(s.originalPath(id)) if err != nil { if os.IsNotExist(err) { return nil, domain.ErrNotFound } return nil, fmt.Errorf("storage.Read: %w", err) } return f, nil } // Delete removes the original file. Returns ErrNotFound if it does not exist. func (s *DiskStorage) Delete(_ context.Context, id uuid.UUID) error { if err := os.Remove(s.originalPath(id)); err != nil { if os.IsNotExist(err) { return domain.ErrNotFound } return fmt.Errorf("storage.Delete: %w", err) } return nil } // Thumbnail returns a JPEG that fits within the configured max width×height // (never upscaled, never cropped). Generated on first call and cached. // Video files are thumbnailed via ffmpeg; other non-image files get a placeholder. func (s *DiskStorage) Thumbnail(ctx context.Context, id uuid.UUID) (io.ReadCloser, error) { return s.serveGenerated(ctx, id, s.thumbCachePath(id), s.thumbWidth, s.thumbHeight) } // Preview returns a JPEG that fits within the configured max width×height // (never upscaled, never cropped). Generated on first call and cached. // Video files are thumbnailed via ffmpeg; other non-image files get a placeholder. func (s *DiskStorage) Preview(ctx context.Context, id uuid.UUID) (io.ReadCloser, error) { return s.serveGenerated(ctx, id, s.previewCachePath(id), s.previewWidth, s.previewHeight) } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- // serveGenerated is the shared implementation for Thumbnail and Preview. // imaging.Thumbnail fits the source within maxW×maxH without upscaling or cropping. // // Resolution order: // 1. Return cached JPEG if present. // 2. Decode as still image (JPEG/PNG/GIF via imaging). // 3. Extract a frame with ffmpeg (video files). // 4. Solid-colour placeholder (archives, unrecognised formats, etc.). func (s *DiskStorage) serveGenerated(ctx context.Context, id uuid.UUID, cachePath string, maxW, maxH int) (io.ReadCloser, error) { // Fast path: cache hit. if f, err := os.Open(cachePath); err == nil { return f, nil } // Verify the original file exists before doing any work. srcPath := s.originalPath(id) if _, err := os.Stat(srcPath); err != nil { if os.IsNotExist(err) { return nil, domain.ErrNotFound } return nil, fmt.Errorf("storage: stat %q: %w", srcPath, err) } // 1. Try still-image decode (JPEG/PNG/GIF). // 2. Try video frame extraction via ffmpeg. // 3. Fall back to placeholder. var img image.Image if decoded, err := imaging.Open(srcPath, imaging.AutoOrientation(true)); err == nil { img = imaging.Thumbnail(decoded, maxW, maxH, imaging.Lanczos) } else if frame, err := extractVideoFrame(ctx, srcPath); err == nil { img = imaging.Thumbnail(frame, maxW, maxH, imaging.Lanczos) } else { img = placeholder(maxW, maxH) } // Write to cache atomically (temp→rename) and return an open reader. if rc, err := writeCache(cachePath, img); err == nil { return rc, nil } // Cache write failed (read-only fs, disk full, …). Serve from an // in-memory buffer so the request still succeeds. var buf bytes.Buffer if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil { return nil, fmt.Errorf("storage: encode in-memory JPEG: %w", err) } return io.NopCloser(&buf), nil } // writeCache encodes img as JPEG to cachePath via an atomic temp→rename write, // then opens and returns the cache file. func writeCache(cachePath string, img image.Image) (io.ReadCloser, error) { dir := filepath.Dir(cachePath) tmp, err := os.CreateTemp(dir, ".cache-*.tmp") if err != nil { return nil, fmt.Errorf("storage: create temp file: %w", err) } tmpName := tmp.Name() encErr := jpeg.Encode(tmp, img, &jpeg.Options{Quality: 85}) closeErr := tmp.Close() if encErr != nil { os.Remove(tmpName) return nil, fmt.Errorf("storage: encode cache JPEG: %w", encErr) } if closeErr != nil { os.Remove(tmpName) return nil, fmt.Errorf("storage: close temp file: %w", closeErr) } if err := os.Rename(tmpName, cachePath); err != nil { os.Remove(tmpName) return nil, fmt.Errorf("storage: rename cache file: %w", err) } f, err := os.Open(cachePath) if err != nil { return nil, fmt.Errorf("storage: open cache file: %w", err) } return f, nil } // extractVideoFrame uses ffmpeg to extract a single frame from a video file. // It seeks 1 second in (keyframe-accurate fast seek) and pipes the frame out // as PNG. If the video is shorter than 1 s the seek is silently ignored by // ffmpeg and the first available frame is returned instead. // Returns an error if ffmpeg is not installed or produces no output. func extractVideoFrame(ctx context.Context, srcPath string) (image.Image, error) { var out bytes.Buffer cmd := exec.CommandContext(ctx, "ffmpeg", "-ss", "1", // fast input seek; ignored gracefully on short files "-i", srcPath, "-vframes", "1", "-f", "image2", "-vcodec", "png", "pipe:1", ) cmd.Stdout = &out cmd.Stderr = io.Discard // suppress ffmpeg progress output if err := cmd.Run(); err != nil || out.Len() == 0 { return nil, fmt.Errorf("ffmpeg frame extract: %w", err) } return imaging.Decode(&out) } // --------------------------------------------------------------------------- // Path helpers // --------------------------------------------------------------------------- func (s *DiskStorage) originalPath(id uuid.UUID) string { return filepath.Join(s.filesPath, id.String()) } func (s *DiskStorage) thumbCachePath(id uuid.UUID) string { return filepath.Join(s.thumbsPath, id.String()+"_thumb.jpg") } func (s *DiskStorage) previewCachePath(id uuid.UUID) string { return filepath.Join(s.thumbsPath, id.String()+"_preview.jpg") } // --------------------------------------------------------------------------- // Image helpers // --------------------------------------------------------------------------- // placeholder returns a solid-colour image of size w×h for files that cannot // be decoded as images. Uses #444455 from the design palette. func placeholder(w, h int) *image.NRGBA { return imaging.New(w, h, color.NRGBA{R: 0x44, G: 0x44, B: 0x55, A: 0xFF}) }