diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-19 19:35:42 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-19 19:35:42 +0300 |
| commit | d2c18b25caffafa64d90195d24bca77315f76d16 (patch) | |
| tree | 64839f04a6fab2b3930584680120185b32e6fe57 /player-server/internal/service | |
| parent | 41bdcb29178fcbcd71fb3663f507736151c018db (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.go | 60 | ||||
| -rw-r--r-- | player-server/internal/service/browse_test.go | 83 | ||||
| -rw-r--r-- | player-server/internal/service/media.go | 14 |
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), |
