Masahiko AMANO 0ae8b81a0b feat(backend): seed MIME types and support all image/video formats
007_seed_data.sql: insert 10 MIME types (4 image, 6 video) with their
canonical extensions into core.mime_types.

disk.go: register golang.org/x/image/webp decoder so imaging.Open
handles WebP still images. Videos (mp4, mov, avi, webm, 3gp, m4v)
continue to go through the ffmpeg frame-extraction path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:21:27 +03:00

258 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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})
}