diff options
Diffstat (limited to 'player-server/internal/thumb')
| -rw-r--r-- | player-server/internal/thumb/resolver.go | 80 | ||||
| -rw-r--r-- | player-server/internal/thumb/resolver_test.go | 97 |
2 files changed, 177 insertions, 0 deletions
diff --git a/player-server/internal/thumb/resolver.go b/player-server/internal/thumb/resolver.go new file mode 100644 index 0000000..a6614d6 --- /dev/null +++ b/player-server/internal/thumb/resolver.go @@ -0,0 +1,80 @@ +package thumb + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "codeberg.org/snonux/player/internal/model" +) + +// ErrNotFound is returned by a Resolver when a media item has no thumbnail +// (and no acceptable fallback) available. Callers translate this sentinel +// to their domain-specific not-found error (e.g. service.ErrNotFound). +var ErrNotFound = errors.New("thumbnail not found") + +// ResolvedFile describes a thumbnail file that has been located on (or +// abstractly resolved from) some backing store. It contains everything a +// caller needs to construct a HTTP file response without itself touching +// the filesystem. +type ResolvedFile struct { + Path string + FileName string + FileSize int64 +} + +// Resolver abstracts the "given a media item, return a thumbnail file" step. +// Pulling this out of browseService lets the service layer avoid direct +// os.Stat calls and lets tests inject a fake resolver instead of writing +// temporary files. +type Resolver interface { + // Resolve returns a ResolvedFile for the media's thumbnail. If the + // media has no thumbnail and no fallback is available, it returns + // ErrNotFound. Any other error indicates a real I/O problem. + Resolve(media *model.Media) (*ResolvedFile, error) +} + +// FSResolver is the production Resolver that stats files on the local +// filesystem. It encapsulates the original logic that lived in +// browseService.GetThumbnail: prefer the generated thumbnail, fall back to +// the original file for images so that cover.jpg-style assets still render. +type FSResolver struct{} + +// NewFSResolver returns a Resolver backed by os.Stat against real paths. +func NewFSResolver() *FSResolver { + return &FSResolver{} +} + +// Resolve looks up the thumbnail file on disk for media, falling back to +// the original AbsPath for image media when the generated thumbnail is +// missing. A missing or empty thumbnail path with no fallback yields +// ErrNotFound so callers can map it to a 404 cleanly. +func (FSResolver) Resolve(media *model.Media) (*ResolvedFile, error) { + if media == nil { + return nil, ErrNotFound + } + if media.ThumbnailPath == "" { + return nil, ErrNotFound + } + if info, err := os.Stat(media.ThumbnailPath); err == nil { + return &ResolvedFile{ + Path: media.ThumbnailPath, + FileName: filepath.Base(media.ThumbnailPath), + FileSize: info.Size(), + }, nil + } else if media.Type == model.MediaTypeImage { + // Generated thumbnail missing: for images, fall back to the + // original file so cover.jpg / folder.jpg etc. still render. + if info, statErr := os.Stat(media.AbsPath); statErr == nil { + return &ResolvedFile{ + Path: media.AbsPath, + FileName: filepath.Base(media.AbsPath), + FileSize: info.Size(), + }, nil + } + return nil, fmt.Errorf("stat thumbnail: %w", err) + } else { + return nil, fmt.Errorf("stat thumbnail: %w", err) + } +} diff --git a/player-server/internal/thumb/resolver_test.go b/player-server/internal/thumb/resolver_test.go new file mode 100644 index 0000000..abf9071 --- /dev/null +++ b/player-server/internal/thumb/resolver_test.go @@ -0,0 +1,97 @@ +package thumb + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "codeberg.org/snonux/player/internal/model" +) + +func TestFSResolver_Resolve(t *testing.T) { + tmpDir := t.TempDir() + thumbPath := filepath.Join(tmpDir, "thumb.jpg") + if err := os.WriteFile(thumbPath, []byte("thumb-bytes"), 0o644); err != nil { + t.Fatalf("write thumb: %v", err) + } + imgPath := filepath.Join(tmpDir, "cover.jpg") + if err := os.WriteFile(imgPath, []byte("imgcontents"), 0o644); err != nil { + t.Fatalf("write img: %v", err) + } + + r := NewFSResolver() + + t.Run("nil media is not found", func(t *testing.T) { + if _, err := r.Resolve(nil); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } + }) + + t.Run("empty thumbnail path is not found", func(t *testing.T) { + _, err := r.Resolve(&model.Media{ID: 1}) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } + }) + + t.Run("existing thumbnail returns resolved file", func(t *testing.T) { + res, err := r.Resolve(&model.Media{ID: 1, ThumbnailPath: thumbPath}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.Path != thumbPath { + t.Fatalf("path = %q, want %q", res.Path, thumbPath) + } + if res.FileName != "thumb.jpg" { + t.Fatalf("file name = %q", res.FileName) + } + if res.FileSize != int64(len("thumb-bytes")) { + t.Fatalf("file size = %d", res.FileSize) + } + }) + + t.Run("image falls back to AbsPath when thumb missing", func(t *testing.T) { + missing := filepath.Join(tmpDir, "missing.jpg") + res, err := r.Resolve(&model.Media{ + ID: 1, + ThumbnailPath: missing, + AbsPath: imgPath, + Type: model.MediaTypeImage, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.Path != imgPath { + t.Fatalf("path = %q, want fallback %q", res.Path, imgPath) + } + }) + + t.Run("non-image with missing thumb returns wrapped stat error", func(t *testing.T) { + missing := filepath.Join(tmpDir, "nope.jpg") + _, err := r.Resolve(&model.Media{ + ID: 1, + ThumbnailPath: missing, + AbsPath: imgPath, + Type: model.MediaTypeVideo, + }) + if err == nil { + t.Fatal("expected error for missing video thumb") + } + if errors.Is(err, ErrNotFound) { + t.Fatalf("did not expect ErrNotFound for video, got %v", err) + } + }) + + t.Run("image with missing thumb and missing AbsPath surfaces stat error", func(t *testing.T) { + _, err := r.Resolve(&model.Media{ + ID: 1, + ThumbnailPath: filepath.Join(tmpDir, "gone.jpg"), + AbsPath: filepath.Join(tmpDir, "also-gone.jpg"), + Type: model.MediaTypeImage, + }) + if err == nil { + t.Fatal("expected error when both files are missing") + } + }) +} |
