summaryrefslogtreecommitdiff
path: root/player-server/internal/thumb
diff options
context:
space:
mode:
Diffstat (limited to 'player-server/internal/thumb')
-rw-r--r--player-server/internal/thumb/resolver.go80
-rw-r--r--player-server/internal/thumb/resolver_test.go97
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")
+ }
+ })
+}