summaryrefslogtreecommitdiff
path: root/player-server/internal/service
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-19 19:35:42 +0300
committerPaul Buetow <paul@buetow.org>2026-05-19 19:35:42 +0300
commitd2c18b25caffafa64d90195d24bca77315f76d16 (patch)
tree64839f04a6fab2b3930584680120185b32e6fe57 /player-server/internal/service
parent41bdcb29178fcbcd71fb3663f507736151c018db (diff)
Introduce thumb.Resolver to decouple browseService from filesystem
browseService.GetThumbnail previously called os.Stat directly, coupling the service layer to the filesystem and forcing tests to write temporary files. Introduce a thumb.Resolver interface plus an FSResolver default, inject it into browseService, and wire NewMediaServiceWithDeps so production constructs the resolver explicitly. Tests can now swap in a fake Resolver without touching disk. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Diffstat (limited to 'player-server/internal/service')
-rw-r--r--player-server/internal/service/browse.go60
-rw-r--r--player-server/internal/service/browse_test.go83
-rw-r--r--player-server/internal/service/media.go14
3 files changed, 130 insertions, 27 deletions
diff --git a/player-server/internal/service/browse.go b/player-server/internal/service/browse.go
index 0294a91..be0ee2c 100644
--- a/player-server/internal/service/browse.go
+++ b/player-server/internal/service/browse.go
@@ -2,6 +2,7 @@ package service
import (
"context"
+ "errors"
"fmt"
mrand "math/rand"
"os"
@@ -12,15 +13,21 @@ import (
"codeberg.org/snonux/player/internal/clock"
"codeberg.org/snonux/player/internal/model"
"codeberg.org/snonux/player/internal/repository"
+ "codeberg.org/snonux/player/internal/thumb"
)
// browseService handles read-only browsing and media streaming operations.
+//
+// The thumbnail resolver is injected so the service layer no longer reaches
+// into the filesystem itself; tests can swap in a fake Resolver instead of
+// writing temporary files.
type browseService struct {
store repository.BrowseServiceStore
clock clock.Clock
mediaRoot string
helper *accessHelper
podcastBrowser PodcastBrowser
+ thumbResolver thumb.Resolver
}
// PodcastBrowser augments a BrowseResult with podcast-specific folders and episodes.
@@ -75,14 +82,27 @@ func (p *podcastBrowseService) AugmentBrowseSet(ctx context.Context, result *Bro
return nil
}
-// NewBrowseService creates a BrowseService.
+// NewBrowseService creates a BrowseService with the production filesystem
+// thumbnail resolver. Use NewBrowseServiceWithResolver to inject a custom
+// Resolver (e.g. a fake in tests).
func NewBrowseService(store repository.BrowseServiceStore, clk clock.Clock, mediaRoot string, helper *accessHelper, browser PodcastBrowser) *browseService {
+ return NewBrowseServiceWithResolver(store, clk, mediaRoot, helper, browser, thumb.NewFSResolver())
+}
+
+// NewBrowseServiceWithResolver creates a BrowseService with a caller-supplied
+// thumbnail Resolver. A nil resolver falls back to the default filesystem
+// implementation so existing callers keep working.
+func NewBrowseServiceWithResolver(store repository.BrowseServiceStore, clk clock.Clock, mediaRoot string, helper *accessHelper, browser PodcastBrowser, resolver thumb.Resolver) *browseService {
+ if resolver == nil {
+ resolver = thumb.NewFSResolver()
+ }
return &browseService{
store: store,
clock: clk,
mediaRoot: mediaRoot,
helper: helper,
podcastBrowser: browser,
+ thumbResolver: resolver,
}
}
@@ -229,32 +249,22 @@ func (s *browseService) GetThumbnail(ctx context.Context, mediaID, userID int64)
if err != nil {
return nil, err
}
- if media.ThumbnailPath == "" {
- // Use the sentinel so handleError maps this to HTTP 404 instead of
- // falling through to the default 500 branch.
- return nil, ErrNotFound
- }
- info, err := os.Stat(media.ThumbnailPath)
- if err == nil {
- return &FileResult{
- Path: media.ThumbnailPath,
- FileName: filepath.Base(media.ThumbnailPath),
- FileSize: info.Size(),
- }, nil
- }
- // If the generated thumbnail is missing, fall back to the original file
- // for image media so that cover.jpg and similar files still render.
- if media.Type == model.MediaTypeImage {
- info, err = os.Stat(media.AbsPath)
- if err == nil {
- return &FileResult{
- Path: media.AbsPath,
- FileName: filepath.Base(media.AbsPath),
- FileSize: info.Size(),
- }, nil
+ // Delegate filesystem access to the injected resolver so the service
+ // layer stays free of os.Stat. The resolver returns thumb.ErrNotFound
+ // for missing thumbnails; map that to the service-level sentinel so
+ // handleError renders an HTTP 404 instead of a 500.
+ resolved, err := s.thumbResolver.Resolve(media)
+ if err != nil {
+ if errors.Is(err, thumb.ErrNotFound) {
+ return nil, ErrNotFound
}
+ return nil, err
}
- return nil, fmt.Errorf("stat thumbnail: %w", err)
+ return &FileResult{
+ Path: resolved.Path,
+ FileName: resolved.FileName,
+ FileSize: resolved.FileSize,
+ }, nil
}
// prefixForParent builds the slash-terminated prefix used for matching paths under parent.
diff --git a/player-server/internal/service/browse_test.go b/player-server/internal/service/browse_test.go
index 81ffde1..655bfe5 100644
--- a/player-server/internal/service/browse_test.go
+++ b/player-server/internal/service/browse_test.go
@@ -8,8 +8,24 @@ import (
"codeberg.org/snonux/player/internal/clock"
"codeberg.org/snonux/player/internal/model"
"codeberg.org/snonux/player/internal/repository"
+ "codeberg.org/snonux/player/internal/thumb"
)
+// fakeResolver is a test double for thumb.Resolver that lets tests assert
+// browseService delegates thumbnail lookup instead of touching the disk.
+type fakeResolver struct {
+ called bool
+ gotMedia *model.Media
+ resolved *thumb.ResolvedFile
+ resolvErr error
+}
+
+func (f *fakeResolver) Resolve(media *model.Media) (*thumb.ResolvedFile, error) {
+ f.called = true
+ f.gotMedia = media
+ return f.resolved, f.resolvErr
+}
+
func TestBrowseService_BrowseSet(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
@@ -177,3 +193,70 @@ func TestBrowseService_BrowseSet_PodcastEpisodes(t *testing.T) {
t.Fatalf("expected undownloaded episode ID 2, got %+v", res.Episodes[0])
}
}
+
+// TestBrowseService_GetThumbnail_DelegatesToResolver verifies the service
+// no longer calls os.Stat directly: GetThumbnail should hand off to the
+// injected thumb.Resolver and translate ResolvedFile -> FileResult.
+func TestBrowseService_GetThumbnail_DelegatesToResolver(t *testing.T) {
+ ctx := context.Background()
+
+ media := &model.Media{ID: 7, SetID: 1, FileName: "a.mp4", ThumbnailPath: "/anywhere/x.jpg"}
+ store := &repository.MockStore{
+ MediaRepo: repository.MockMediaRepo{
+ GetMediaByIDFunc: func(ctx context.Context, id int64) (*model.Media, error) {
+ return media, nil
+ },
+ },
+ UserRepo: repository.MockUserRepo{
+ GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) {
+ return &model.User{ID: id, IsAdmin: true}, nil
+ },
+ },
+ }
+
+ t.Run("resolver hit becomes FileResult", func(t *testing.T) {
+ fake := &fakeResolver{resolved: &thumb.ResolvedFile{Path: "/anywhere/x.jpg", FileName: "x.jpg", FileSize: 42}}
+ svc := NewBrowseServiceWithResolver(store, clock.RealClock{}, "/tmp/media", &accessHelper{store: store}, nil, fake)
+ res, err := svc.GetThumbnail(ctx, 7, 1)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !fake.called {
+ t.Fatal("expected resolver to be invoked")
+ }
+ if fake.gotMedia == nil || fake.gotMedia.ID != 7 {
+ t.Fatalf("resolver received wrong media: %+v", fake.gotMedia)
+ }
+ if res.Path != "/anywhere/x.jpg" || res.FileName != "x.jpg" || res.FileSize != 42 {
+ t.Fatalf("unexpected FileResult: %+v", res)
+ }
+ })
+
+ t.Run("resolver ErrNotFound maps to service ErrNotFound", func(t *testing.T) {
+ fake := &fakeResolver{resolvErr: thumb.ErrNotFound}
+ svc := NewBrowseServiceWithResolver(store, clock.RealClock{}, "/tmp/media", &accessHelper{store: store}, nil, fake)
+ _, err := svc.GetThumbnail(ctx, 7, 1)
+ if !errors.Is(err, ErrNotFound) {
+ t.Fatalf("expected ErrNotFound, got %v", err)
+ }
+ })
+
+ t.Run("other resolver errors propagate", func(t *testing.T) {
+ boom := errors.New("disk on fire")
+ fake := &fakeResolver{resolvErr: boom}
+ svc := NewBrowseServiceWithResolver(store, clock.RealClock{}, "/tmp/media", &accessHelper{store: store}, nil, fake)
+ _, err := svc.GetThumbnail(ctx, 7, 1)
+ if err == nil || errors.Is(err, ErrNotFound) {
+ t.Fatalf("expected non-not-found error, got %v", err)
+ }
+ })
+
+ t.Run("nil resolver falls back to default", func(t *testing.T) {
+ // NewBrowseServiceWithResolver(nil resolver) should still work and
+ // not panic — the constructor swaps in the FS resolver.
+ svc := NewBrowseServiceWithResolver(store, clock.RealClock{}, "/tmp/media", &accessHelper{store: store}, nil, nil)
+ if svc == nil {
+ t.Fatal("expected non-nil service")
+ }
+ })
+}
diff --git a/player-server/internal/service/media.go b/player-server/internal/service/media.go
index 38c670f..2640d0c 100644
--- a/player-server/internal/service/media.go
+++ b/player-server/internal/service/media.go
@@ -33,11 +33,21 @@ func NewMediaService(store repository.MediaServiceStore, clk clock.Clock, mediaR
return NewMediaServiceWithPodcastBrowser(store, clk, mediaRoot, thumbGen, prober, nil)
}
-// NewMediaServiceWithPodcastBrowser creates a MediaService with an optional PodcastBrowser.
+// NewMediaServiceWithPodcastBrowser creates a MediaService with an optional
+// PodcastBrowser and the default filesystem thumbnail resolver. For
+// dependency-injected setups (e.g. tests that want to avoid touching disk
+// for thumbnails) use NewMediaServiceWithDeps.
func NewMediaServiceWithPodcastBrowser(store repository.MediaServiceStore, clk clock.Clock, mediaRoot string, thumbGen thumb.Generator, prober probe.Prober, browser PodcastBrowser) *mediaService {
+ return NewMediaServiceWithDeps(store, clk, mediaRoot, thumbGen, prober, browser, thumb.NewFSResolver())
+}
+
+// NewMediaServiceWithDeps creates a MediaService with all collaborators
+// supplied explicitly, including a thumbnail Resolver. A nil resolver
+// falls back to the default filesystem implementation.
+func NewMediaServiceWithDeps(store repository.MediaServiceStore, clk clock.Clock, mediaRoot string, thumbGen thumb.Generator, prober probe.Prober, browser PodcastBrowser, thumbResolver thumb.Resolver) *mediaService {
helper := &accessHelper{store: store}
return &mediaService{
- MediaBrowseService: NewBrowseService(store, clk, mediaRoot, helper, browser),
+ MediaBrowseService: NewBrowseServiceWithResolver(store, clk, mediaRoot, helper, browser, thumbResolver),
MediaWriteService: NewWriteService(store, clk, mediaRoot, thumbGen, prober, helper),
MediaShareService: NewShareService(store, clk, helper),
MediaTagService: NewTagService(store, helper),