summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd/mediaplayer/main.go4
-rw-r--r--internal/api/handlers.go16
-rw-r--r--internal/api/handlers_more_test.go37
-rw-r--r--internal/service/media.go82
-rw-r--r--internal/service/media_test.go406
-rw-r--r--internal/service/no_rows_test.go12
6 files changed, 510 insertions, 47 deletions
diff --git a/cmd/mediaplayer/main.go b/cmd/mediaplayer/main.go
index 3419763..7e5df5c 100644
--- a/cmd/mediaplayer/main.go
+++ b/cmd/mediaplayer/main.go
@@ -76,10 +76,10 @@ func run(args []string) error {
hasher := auth.NewBCryptHasher(12)
sm := auth.NewSessionManager(store, clk, time.Duration(cfg.SessionTimeoutHours)*time.Hour)
- mediaSvc := service.NewMediaService(store, clk, cfg.MediaRoot)
-
prober := probe.NewFFProber()
thumbGen := thumb.NewFFmpegGenerator()
+ mediaSvc := service.NewMediaService(store, clk, cfg.MediaRoot, thumbGen, prober)
+
fsScanner := scanner.NewFSScanner(store, prober, thumbGen, clk, cfg.MediaRoot)
adminSvc := service.NewAdminService(store, clk, hasher, fsScanner, cfg.MediaRoot)
diff --git a/internal/api/handlers.go b/internal/api/handlers.go
index 672891d..3ee4445 100644
--- a/internal/api/handlers.go
+++ b/internal/api/handlers.go
@@ -267,6 +267,14 @@ func (s *Server) handleSetCover(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.mediaSvc.RegenerateSetCover(r.Context(), setID, userIDFromContext(r)); err != nil {
+ if errors.Is(err, service.ErrNotFound) {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
+ return
+ }
+ if errors.Is(err, service.ErrForbidden) {
+ writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
+ return
+ }
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
@@ -597,6 +605,14 @@ func (s *Server) handleRegenThumbnail(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.mediaSvc.RegenerateThumbnail(r.Context(), id, userIDFromContext(r)); err != nil {
+ if errors.Is(err, service.ErrNotFound) {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
+ return
+ }
+ if errors.Is(err, service.ErrForbidden) {
+ writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
+ return
+ }
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
diff --git a/internal/api/handlers_more_test.go b/internal/api/handlers_more_test.go
index d2ef424..263041c 100644
--- a/internal/api/handlers_more_test.go
+++ b/internal/api/handlers_more_test.go
@@ -335,6 +335,8 @@ func TestServer_SetCover(t *testing.T) {
{"nil service", "1", true, nil, http.StatusNotImplemented},
{"invalid id", "abc", false, nil, http.StatusBadRequest},
{"service error", "1", false, errors.New("boom"), http.StatusInternalServerError},
+ {"not found", "1", false, service.ErrNotFound, http.StatusNotFound},
+ {"forbidden", "1", false, service.ErrForbidden, http.StatusForbidden},
{"ok", "1", false, nil, http.StatusOK},
}
@@ -743,6 +745,41 @@ func TestServer_RegenThumbnail(t *testing.T) {
}
}
+func TestServer_RegenThumbnail_errorMapping(t *testing.T) {
+ store := buildSessionStore(1)
+ sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour)
+ cfg := &internal.Config{SessionTimeoutHours: 24}
+
+ tests := []struct {
+ name string
+ svcErr error
+ wantCode int
+ }{
+ {"not found", service.ErrNotFound, http.StatusNotFound},
+ {"forbidden", service.ErrForbidden, http.StatusForbidden},
+ {"internal error", errors.New("boom"), http.StatusInternalServerError},
+ {"ok", nil, http.StatusOK},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ms := &service.MockMediaService{
+ RegenerateThumbnailFunc: func(ctx context.Context, mediaID, userID int64) error {
+ return tt.svcErr
+ },
+ }
+ srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil)
+ req := httptest.NewRequest(http.MethodPost, "/api/media/1/thumbnail", nil)
+ req.AddCookie(sessionCookieForStore(t, store, sm, 1))
+ rr := httptest.NewRecorder()
+ srv.ServeHTTP(rr, req)
+ if rr.Code != tt.wantCode {
+ t.Fatalf("expected %d, got %d", tt.wantCode, rr.Code)
+ }
+ })
+ }
+}
+
// ------------------------------------------------------------------
// Shares
// ------------------------------------------------------------------
diff --git a/internal/service/media.go b/internal/service/media.go
index 6f4b2db..05dd678 100644
--- a/internal/service/media.go
+++ b/internal/service/media.go
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"io"
+ mrand "math/rand"
"os"
"path/filepath"
"strings"
@@ -14,7 +15,9 @@ import (
"codeberg.org/snonux/play/internal/clock"
"codeberg.org/snonux/play/internal/model"
+ "codeberg.org/snonux/play/internal/probe"
"codeberg.org/snonux/play/internal/repository"
+ "codeberg.org/snonux/play/internal/thumb"
)
// mediaService is the concrete implementation of MediaService.
@@ -22,14 +25,18 @@ type mediaService struct {
store repository.MediaServiceStore
clock clock.Clock
mediaRoot string
+ thumbGen thumb.Generator
+ prober probe.Prober
}
// NewMediaService creates a concrete MediaService.
-func NewMediaService(store repository.MediaServiceStore, clk clock.Clock, mediaRoot string) MediaService {
+func NewMediaService(store repository.MediaServiceStore, clk clock.Clock, mediaRoot string, thumbGen thumb.Generator, prober probe.Prober) MediaService {
return &mediaService{
store: store,
clock: clk,
mediaRoot: mediaRoot,
+ thumbGen: thumbGen,
+ prober: prober,
}
}
@@ -291,18 +298,85 @@ func (s *mediaService) GetThumbnail(ctx context.Context, mediaID, userID int64)
}
func (s *mediaService) RegenerateThumbnail(ctx context.Context, mediaID, userID int64) error {
- _, err := s.verifyModifyAccess(ctx, mediaID, userID)
+ media, err := s.verifyModifyAccess(ctx, mediaID, userID)
if err != nil {
return err
}
- return errors.New("not implemented")
+ if media.Type != model.MediaTypeVideo {
+ return errors.New("thumbnails can only be generated for video files")
+ }
+
+ meta, err := s.prober.Probe(ctx, media.AbsPath)
+ if err != nil {
+ return fmt.Errorf("probe media: %w", err)
+ }
+
+ thumbDir := filepath.Join(filepath.Dir(media.AbsPath), ".thumbnails")
+ if err := os.MkdirAll(thumbDir, 0o755); err != nil {
+ return fmt.Errorf("mkdir thumbnails: %w", err)
+ }
+ thumbName := strings.TrimSuffix(filepath.Base(media.AbsPath), filepath.Ext(media.AbsPath)) + ".jpg"
+ thumbnailPath := filepath.Join(thumbDir, thumbName)
+
+ if err := s.thumbGen.Generate(ctx, media.AbsPath, thumbnailPath, meta.Duration); err != nil {
+ return fmt.Errorf("generate thumbnail: %w", err)
+ }
+
+ media.ThumbnailPath = thumbnailPath
+ if err := s.store.UpdateMedia(ctx, media); err != nil {
+ return fmt.Errorf("update media: %w", err)
+ }
+ return nil
}
func (s *mediaService) RegenerateSetCover(ctx context.Context, setID, userID int64) error {
if err := s.verifySetModifyAccess(ctx, setID, userID); err != nil {
return err
}
- return errors.New("not implemented")
+
+ set, err := s.store.GetSetByID(ctx, setID)
+ if err != nil {
+ return fmt.Errorf("get set: %w", err)
+ }
+ if set == nil {
+ return ErrNotFound
+ }
+
+ media, err := s.store.ListMedia(ctx, repository.MediaFilter{SetID: &setID})
+ if err != nil {
+ return fmt.Errorf("list media: %w", err)
+ }
+
+ var candidates []model.Media
+ for _, m := range media {
+ if m.Type == model.MediaTypeVideo && m.DeletedAt == nil {
+ candidates = append(candidates, m)
+ }
+ }
+ if len(candidates) == 0 {
+ return errors.New("no video files available for cover")
+ }
+
+ candidate := candidates[0]
+ if len(candidates) > 1 {
+ candidate = candidates[mrand.Intn(len(candidates))]
+ }
+
+ coverPath := filepath.Join(filepath.Clean(filepath.Join(s.mediaRoot, set.RootPath)), ".cover.jpg")
+ meta, err := s.prober.Probe(ctx, candidate.AbsPath)
+ if err != nil {
+ return fmt.Errorf("probe cover candidate: %w", err)
+ }
+
+ if err := s.thumbGen.Generate(ctx, candidate.AbsPath, coverPath, meta.Duration); err != nil {
+ return fmt.Errorf("generate cover: %w", err)
+ }
+
+ set.CoverThumbnailPath = coverPath
+ if err := s.store.UpdateSet(ctx, set); err != nil {
+ return fmt.Errorf("update set: %w", err)
+ }
+ return nil
}
func (s *mediaService) ToggleFavorite(ctx context.Context, userID, mediaID int64) (bool, error) {
diff --git a/internal/service/media_test.go b/internal/service/media_test.go
index 33912b4..fd1784d 100644
--- a/internal/service/media_test.go
+++ b/internal/service/media_test.go
@@ -3,6 +3,8 @@ package service
import (
"context"
"errors"
+ "os"
+ "path/filepath"
"strings"
"testing"
"time"
@@ -102,7 +104,7 @@ func TestMediaService_ListSets(t *testing.T) {
},
},
}
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
sets, err := svc.ListSets(ctx, tt.userID)
if tt.wantErr {
if err == nil {
@@ -217,7 +219,7 @@ func TestMediaService_GetMediaDetail(t *testing.T) {
},
},
}
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
detail, err := svc.GetMediaDetail(ctx, tt.mediaID, tt.userID)
if tt.wantErr {
if err == nil {
@@ -343,7 +345,7 @@ func TestMediaService_StreamMedia_Access(t *testing.T) {
},
},
}
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
res, err := svc.StreamMedia(ctx, tt.mediaID, tt.userID)
if tt.wantErr != nil {
if err == nil {
@@ -378,7 +380,7 @@ func TestMediaService_StreamMedia(t *testing.T) {
},
},
}
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
res, err := svc.StreamMedia(ctx, 1, 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -464,7 +466,7 @@ func TestMediaService_DownloadMedia_Access(t *testing.T) {
},
},
}
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
res, err := svc.DownloadMedia(ctx, tt.mediaID, tt.userID)
if tt.wantErr != nil {
if err == nil {
@@ -509,7 +511,7 @@ func TestMediaService_ToggleFavorite(t *testing.T) {
},
},
}
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
fav, err := svc.ToggleFavorite(ctx, 1, 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -586,7 +588,7 @@ func TestMediaService_AssignTag(t *testing.T) {
},
},
}
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
err := svc.AssignTag(ctx, 1, 1, "rock")
if tt.wantErr {
if err == nil {
@@ -660,7 +662,7 @@ func TestMediaService_RemoveTag(t *testing.T) {
},
},
}
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
err := svc.RemoveTag(ctx, 1, 1, "rock")
if tt.wantErr {
if err == nil {
@@ -697,7 +699,7 @@ func TestMediaService_SoftDeleteMedia(t *testing.T) {
},
},
}
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
if err := svc.SoftDeleteMedia(ctx, 1, 1); err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -727,7 +729,7 @@ func TestMediaService_RestoreMedia(t *testing.T) {
},
},
}
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
if err := svc.RestoreMedia(ctx, 1, 1); err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -810,7 +812,7 @@ func TestMediaService_UploadMedia(t *testing.T) {
},
},
}
- svc := NewMediaService(store, newMockClock(), tmpDir)
+ svc := NewMediaService(store, newMockClock(), tmpDir, nil, nil)
data := strings.NewReader("hello world")
media, err := svc.UploadMedia(ctx, 1, 1, tt.filename, data, 11)
if tt.wantErr {
@@ -850,7 +852,7 @@ func TestMediaService_CreateShare(t *testing.T) {
},
},
}
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
share, err := svc.CreateShare(ctx, 1, 1, now.Add(24*time.Hour))
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -903,7 +905,7 @@ func TestMediaService_ValidateShareToken(t *testing.T) {
},
},
}
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
res, err := svc.ValidateShareToken(ctx, "abc")
if tt.wantValid {
if err != nil {
@@ -967,7 +969,7 @@ func TestMediaService_StreamSharedMedia(t *testing.T) {
},
},
}
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
res, err := svc.StreamSharedMedia(ctx, "abc")
if tt.wantErr {
if err == nil {
@@ -1034,7 +1036,7 @@ func TestMediaService_Notes(t *testing.T) {
},
},
}
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
var err error
switch tt.method {
case "get":
@@ -1061,6 +1063,30 @@ func intPtr(i int) *int {
return &i
}
+// mockThumbGenerator is a test fake for thumb.Generator.
+type mockThumbGenerator struct {
+ GenerateFunc func(ctx context.Context, inputPath, outputPath string, duration float64) error
+}
+
+func (m *mockThumbGenerator) Generate(ctx context.Context, inputPath, outputPath string, duration float64) error {
+ if m.GenerateFunc != nil {
+ return m.GenerateFunc(ctx, inputPath, outputPath, duration)
+ }
+ return nil
+}
+
+// mockProber is a test fake for probe.Prober.
+type mockProber struct {
+ ProbeFunc func(ctx context.Context, path string) (*model.Metadata, error)
+}
+
+func (m *mockProber) Probe(ctx context.Context, path string) (*model.Metadata, error) {
+ if m.ProbeFunc != nil {
+ return m.ProbeFunc(ctx, path)
+ }
+ return &model.Metadata{}, nil
+}
+
func TestMediaService_ViewerCannotMutate(t *testing.T) {
ctx := context.Background()
@@ -1091,7 +1117,7 @@ func TestMediaService_ViewerCannotMutate(t *testing.T) {
t.Run("viewer cannot soft delete", func(t *testing.T) {
store := makeViewerStore(1, 1)
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
err := svc.SoftDeleteMedia(ctx, 1, 2)
if !errors.Is(err, ErrForbidden) {
t.Fatalf("expected ErrForbidden, got %v", err)
@@ -1100,7 +1126,7 @@ func TestMediaService_ViewerCannotMutate(t *testing.T) {
t.Run("viewer cannot restore", func(t *testing.T) {
store := makeViewerStore(1, 1)
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
err := svc.RestoreMedia(ctx, 1, 2)
if !errors.Is(err, ErrForbidden) {
t.Fatalf("expected ErrForbidden, got %v", err)
@@ -1120,7 +1146,7 @@ func TestMediaService_ViewerCannotMutate(t *testing.T) {
},
},
}
- svc := NewMediaService(store, newMockClock(), t.TempDir())
+ svc := NewMediaService(store, newMockClock(), t.TempDir(), nil, nil)
_, err := svc.UploadMedia(ctx, 1, 2, "song.mp3", strings.NewReader("data"), 4)
if !errors.Is(err, ErrForbidden) {
t.Fatalf("expected ErrForbidden, got %v", err)
@@ -1129,7 +1155,7 @@ func TestMediaService_ViewerCannotMutate(t *testing.T) {
t.Run("viewer cannot regenerate thumbnail", func(t *testing.T) {
store := makeViewerStore(1, 1)
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
err := svc.RegenerateThumbnail(ctx, 1, 2)
if !errors.Is(err, ErrForbidden) {
t.Fatalf("expected ErrForbidden, got %v", err)
@@ -1149,7 +1175,7 @@ func TestMediaService_ViewerCannotMutate(t *testing.T) {
},
},
}
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
err := svc.RegenerateSetCover(ctx, 1, 2)
if !errors.Is(err, ErrForbidden) {
t.Fatalf("expected ErrForbidden, got %v", err)
@@ -1190,7 +1216,7 @@ func TestMediaService_UnauthorizedAccessDenied(t *testing.T) {
t.Run("unauthorized cannot get detail", func(t *testing.T) {
store := makeUnauthorizedStore(1, 1)
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
_, err := svc.GetMediaDetail(ctx, 1, 9)
if !errors.Is(err, ErrForbidden) {
t.Fatalf("expected ErrForbidden, got %v", err)
@@ -1215,7 +1241,7 @@ func TestMediaService_UnauthorizedAccessDenied(t *testing.T) {
},
},
}
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
res, err := svc.ListMedia(ctx, 9, repository.MediaFilter{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -1227,7 +1253,7 @@ func TestMediaService_UnauthorizedAccessDenied(t *testing.T) {
t.Run("unauthorized cannot favorite", func(t *testing.T) {
store := makeUnauthorizedStore(1, 1)
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
_, err := svc.ToggleFavorite(ctx, 9, 1)
if !errors.Is(err, ErrForbidden) {
t.Fatalf("expected ErrForbidden, got %v", err)
@@ -1236,7 +1262,7 @@ func TestMediaService_UnauthorizedAccessDenied(t *testing.T) {
t.Run("unauthorized cannot assign tag", func(t *testing.T) {
store := makeUnauthorizedStore(1, 1)
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
err := svc.AssignTag(ctx, 1, 9, "rock")
if !errors.Is(err, ErrForbidden) {
t.Fatalf("expected ErrForbidden, got %v", err)
@@ -1245,7 +1271,7 @@ func TestMediaService_UnauthorizedAccessDenied(t *testing.T) {
t.Run("unauthorized cannot remove tag", func(t *testing.T) {
store := makeUnauthorizedStore(1, 1)
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
err := svc.RemoveTag(ctx, 1, 9, "rock")
if !errors.Is(err, ErrForbidden) {
t.Fatalf("expected ErrForbidden, got %v", err)
@@ -1254,7 +1280,7 @@ func TestMediaService_UnauthorizedAccessDenied(t *testing.T) {
t.Run("unauthorized cannot get note", func(t *testing.T) {
store := makeUnauthorizedStore(1, 1)
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
_, err := svc.GetNote(ctx, 1, 9)
if !errors.Is(err, ErrForbidden) {
t.Fatalf("expected ErrForbidden, got %v", err)
@@ -1263,7 +1289,7 @@ func TestMediaService_UnauthorizedAccessDenied(t *testing.T) {
t.Run("unauthorized cannot upsert note", func(t *testing.T) {
store := makeUnauthorizedStore(1, 1)
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
err := svc.UpsertNote(ctx, &model.Note{MediaID: 1, UserID: 9, Content: "hello"})
if !errors.Is(err, ErrForbidden) {
t.Fatalf("expected ErrForbidden, got %v", err)
@@ -1272,7 +1298,7 @@ func TestMediaService_UnauthorizedAccessDenied(t *testing.T) {
t.Run("unauthorized cannot delete note", func(t *testing.T) {
store := makeUnauthorizedStore(1, 1)
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
err := svc.DeleteNote(ctx, 1, 9)
if !errors.Is(err, ErrForbidden) {
t.Fatalf("expected ErrForbidden, got %v", err)
@@ -1281,7 +1307,7 @@ func TestMediaService_UnauthorizedAccessDenied(t *testing.T) {
t.Run("unauthorized cannot create share", func(t *testing.T) {
store := makeUnauthorizedStore(1, 1)
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
_, err := svc.CreateShare(ctx, 9, 1, time.Now().Add(time.Hour))
if !errors.Is(err, ErrForbidden) {
t.Fatalf("expected ErrForbidden, got %v", err)
@@ -1290,7 +1316,7 @@ func TestMediaService_UnauthorizedAccessDenied(t *testing.T) {
t.Run("unauthorized cannot list shares", func(t *testing.T) {
store := makeUnauthorizedStore(1, 1)
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
_, err := svc.ListShares(ctx, 1, 9)
if !errors.Is(err, ErrForbidden) {
t.Fatalf("expected ErrForbidden, got %v", err)
@@ -1316,7 +1342,7 @@ func TestMediaService_UnauthorizedAccessDenied(t *testing.T) {
},
},
}
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
err := svc.SoftDeleteMedia(ctx, 1, 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -1342,7 +1368,7 @@ func TestMediaService_UnauthorizedAccessDenied(t *testing.T) {
},
},
}
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
err := svc.RestoreMedia(ctx, 1, 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -1368,7 +1394,7 @@ func TestMediaService_UnauthorizedAccessDenied(t *testing.T) {
},
},
}
- svc := NewMediaService(store, newMockClock(), tmpDir)
+ svc := NewMediaService(store, newMockClock(), tmpDir, nil, nil)
media, err := svc.UploadMedia(ctx, 1, 2, "song.mp3", strings.NewReader("data"), 4)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -1395,7 +1421,7 @@ func TestMediaService_ListMedia_AdminAndUserFiltering(t *testing.T) {
},
},
}
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
res, err := svc.ListMedia(ctx, 1, repository.MediaFilter{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -1426,7 +1452,7 @@ func TestMediaService_ListMedia_AdminAndUserFiltering(t *testing.T) {
},
},
}
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
res, err := svc.ListMedia(ctx, 2, repository.MediaFilter{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -1436,3 +1462,313 @@ func TestMediaService_ListMedia_AdminAndUserFiltering(t *testing.T) {
}
})
}
+
+func TestMediaService_RegenerateThumbnail(t *testing.T) {
+ ctx := context.Background()
+ makeStore := func(media *model.Media) *repository.MockStore {
+ return &repository.MockStore{
+ MediaRepo: repository.MockMediaRepo{
+ GetMediaByIDFunc: func(ctx context.Context, id int64) (*model.Media, error) {
+ return media, nil
+ },
+ UpdateMediaFunc: func(ctx context.Context, m *model.Media) error {
+ return nil
+ },
+ },
+ UserRepo: repository.MockUserRepo{
+ GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) {
+ if id == 1 {
+ return &model.User{ID: 1, IsAdmin: true}, nil
+ }
+ return &model.User{ID: id, IsAdmin: false}, nil
+ },
+ },
+ SetRepo: repository.MockSetRepo{
+ GetSetByIDFunc: func(ctx context.Context, id int64) (*model.Set, error) {
+ return &model.Set{ID: id}, nil
+ },
+ },
+ SetPermissionRepo: repository.MockSetPermissionRepo{
+ GetPermissionFunc: func(ctx context.Context, setID, userID int64) (*model.SetPermission, error) {
+ return nil, nil
+ },
+ },
+ }
+ }
+
+ t.Run("admin can regenerate thumbnail", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ mediaPath := filepath.Join(tmpDir, "video.mp4")
+ _ = os.WriteFile(mediaPath, []byte("fake"), 0o644)
+ media := &model.Media{ID: 1, SetID: 1, AbsPath: mediaPath, Type: model.MediaTypeVideo}
+ store := makeStore(media)
+ thumbGen := &mockThumbGenerator{}
+ prober := &mockProber{ProbeFunc: func(ctx context.Context, path string) (*model.Metadata, error) {
+ return &model.Metadata{Duration: 60}, nil
+ }}
+ svc := NewMediaService(store, newMockClock(), tmpDir, thumbGen, prober)
+ if err := svc.RegenerateThumbnail(ctx, 1, 1); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+
+ t.Run("owner can regenerate thumbnail", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ mediaPath := filepath.Join(tmpDir, "video.mp4")
+ _ = os.WriteFile(mediaPath, []byte("fake"), 0o644)
+ media := &model.Media{ID: 1, SetID: 1, AbsPath: mediaPath, Type: model.MediaTypeVideo}
+ store := makeStore(media)
+ store.SetRepo = repository.MockSetRepo{
+ GetSetByIDFunc: func(ctx context.Context, id int64) (*model.Set, error) {
+ return &model.Set{ID: id, Permissions: []model.SetPermission{{SetID: id, UserID: 2, Role: model.RoleOwner}}}, nil
+ },
+ }
+ thumbGen := &mockThumbGenerator{}
+ prober := &mockProber{ProbeFunc: func(ctx context.Context, path string) (*model.Metadata, error) {
+ return &model.Metadata{Duration: 60}, nil
+ }}
+ svc := NewMediaService(store, newMockClock(), tmpDir, thumbGen, prober)
+ if err := svc.RegenerateThumbnail(ctx, 1, 2); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+
+ t.Run("viewer cannot regenerate thumbnail", func(t *testing.T) {
+ media := &model.Media{ID: 1, SetID: 1, AbsPath: "/tmp/video.mp4", Type: model.MediaTypeVideo}
+ store := makeStore(media)
+ store.SetRepo = repository.MockSetRepo{
+ GetSetByIDFunc: func(ctx context.Context, id int64) (*model.Set, error) {
+ return &model.Set{ID: id, Permissions: []model.SetPermission{{SetID: id, UserID: 2, Role: model.RoleViewer}}}, nil
+ },
+ }
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
+ err := svc.RegenerateThumbnail(ctx, 1, 2)
+ if !errors.Is(err, ErrForbidden) {
+ t.Fatalf("expected ErrForbidden, got %v", err)
+ }
+ })
+
+ t.Run("not found", func(t *testing.T) {
+ store := makeStore(nil)
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
+ err := svc.RegenerateThumbnail(ctx, 1, 1)
+ if !errors.Is(err, ErrNotFound) {
+ t.Fatalf("expected ErrNotFound, got %v", err)
+ }
+ })
+
+ t.Run("audio file rejected", func(t *testing.T) {
+ media := &model.Media{ID: 1, SetID: 1, AbsPath: "/tmp/song.mp3", Type: model.MediaTypeAudio}
+ store := makeStore(media)
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
+ err := svc.RegenerateThumbnail(ctx, 1, 1)
+ if err == nil {
+ t.Fatal("expected error for audio file")
+ }
+ })
+
+ t.Run("probe failure", func(t *testing.T) {
+ media := &model.Media{ID: 1, SetID: 1, AbsPath: "/tmp/video.mp4", Type: model.MediaTypeVideo}
+ store := makeStore(media)
+ prober := &mockProber{ProbeFunc: func(ctx context.Context, path string) (*model.Metadata, error) {
+ return nil, errors.New("probe err")
+ }}
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, prober)
+ err := svc.RegenerateThumbnail(ctx, 1, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ })
+
+ t.Run("thumb generation failure", func(t *testing.T) {
+ media := &model.Media{ID: 1, SetID: 1, AbsPath: "/tmp/video.mp4", Type: model.MediaTypeVideo}
+ store := makeStore(media)
+ thumbGen := &mockThumbGenerator{GenerateFunc: func(ctx context.Context, inputPath, outputPath string, duration float64) error {
+ return errors.New("thumb err")
+ }}
+ prober := &mockProber{ProbeFunc: func(ctx context.Context, path string) (*model.Metadata, error) {
+ return &model.Metadata{Duration: 60}, nil
+ }}
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", thumbGen, prober)
+ err := svc.RegenerateThumbnail(ctx, 1, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ })
+
+ t.Run("update media failure", func(t *testing.T) {
+ media := &model.Media{ID: 1, SetID: 1, AbsPath: "/tmp/video.mp4", Type: model.MediaTypeVideo}
+ store := makeStore(media)
+ store.MediaRepo.UpdateMediaFunc = func(ctx context.Context, m *model.Media) error {
+ return errors.New("update err")
+ }
+ thumbGen := &mockThumbGenerator{}
+ prober := &mockProber{ProbeFunc: func(ctx context.Context, path string) (*model.Metadata, error) {
+ return &model.Metadata{Duration: 60}, nil
+ }}
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", thumbGen, prober)
+ err := svc.RegenerateThumbnail(ctx, 1, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ })
+}
+
+func TestMediaService_RegenerateSetCover(t *testing.T) {
+ ctx := context.Background()
+ makeStore := func(setID int64, media []model.Media, set *model.Set) *repository.MockStore {
+ return &repository.MockStore{
+ SetRepo: repository.MockSetRepo{
+ GetSetByIDFunc: func(ctx context.Context, id int64) (*model.Set, error) {
+ if set != nil && set.ID == id {
+ return set, nil
+ }
+ return nil, nil
+ },
+ UpdateSetFunc: func(ctx context.Context, s *model.Set) error {
+ return nil
+ },
+ },
+ UserRepo: repository.MockUserRepo{
+ GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) {
+ if id == 1 {
+ return &model.User{ID: 1, IsAdmin: true}, nil
+ }
+ return &model.User{ID: id, IsAdmin: false}, nil
+ },
+ },
+ SetPermissionRepo: repository.MockSetPermissionRepo{
+ GetPermissionFunc: func(ctx context.Context, sid, uid int64) (*model.SetPermission, error) {
+ return nil, nil
+ },
+ },
+ MediaRepo: repository.MockMediaRepo{
+ ListMediaFunc: func(ctx context.Context, filter repository.MediaFilter) ([]model.Media, error) {
+ return media, nil
+ },
+ },
+ }
+ }
+
+ t.Run("admin can regenerate cover", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ videoPath := filepath.Join(tmpDir, "video.mp4")
+ _ = os.WriteFile(videoPath, []byte("fake"), 0o644)
+ set := &model.Set{ID: 1, RootPath: "music"}
+ media := []model.Media{{ID: 1, SetID: 1, AbsPath: videoPath, Type: model.MediaTypeVideo}}
+ store := makeStore(1, media, set)
+ thumbGen := &mockThumbGenerator{}
+ prober := &mockProber{ProbeFunc: func(ctx context.Context, path string) (*model.Metadata, error) {
+ return &model.Metadata{Duration: 60}, nil
+ }}
+ svc := NewMediaService(store, newMockClock(), tmpDir, thumbGen, prober)
+ if err := svc.RegenerateSetCover(ctx, 1, 1); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+
+ t.Run("owner can regenerate cover", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ videoPath := filepath.Join(tmpDir, "video.mp4")
+ _ = os.WriteFile(videoPath, []byte("fake"), 0o644)
+ set := &model.Set{ID: 1, RootPath: "music", Permissions: []model.SetPermission{{SetID: 1, UserID: 2, Role: model.RoleOwner}}}
+ media := []model.Media{{ID: 1, SetID: 1, AbsPath: videoPath, Type: model.MediaTypeVideo}}
+ store := makeStore(1, media, set)
+ thumbGen := &mockThumbGenerator{}
+ prober := &mockProber{ProbeFunc: func(ctx context.Context, path string) (*model.Metadata, error) {
+ return &model.Metadata{Duration: 60}, nil
+ }}
+ svc := NewMediaService(store, newMockClock(), tmpDir, thumbGen, prober)
+ if err := svc.RegenerateSetCover(ctx, 1, 2); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+
+ t.Run("viewer cannot regenerate cover", func(t *testing.T) {
+ set := &model.Set{ID: 1, RootPath: "music", Permissions: []model.SetPermission{{SetID: 1, UserID: 2, Role: model.RoleViewer}}}
+ store := makeStore(1, nil, set)
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
+ err := svc.RegenerateSetCover(ctx, 1, 2)
+ if !errors.Is(err, ErrForbidden) {
+ t.Fatalf("expected ErrForbidden, got %v", err)
+ }
+ })
+
+ t.Run("set not found", func(t *testing.T) {
+ store := makeStore(1, nil, nil)
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
+ err := svc.RegenerateSetCover(ctx, 1, 1)
+ if !errors.Is(err, ErrNotFound) {
+ t.Fatalf("expected ErrNotFound, got %v", err)
+ }
+ })
+
+ t.Run("no video files", func(t *testing.T) {
+ set := &model.Set{ID: 1, RootPath: "music"}
+ media := []model.Media{{ID: 1, SetID: 1, AbsPath: "/tmp/song.mp3", Type: model.MediaTypeAudio}}
+ store := makeStore(1, media, set)
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
+ err := svc.RegenerateSetCover(ctx, 1, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ })
+
+ t.Run("probe failure", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ videoPath := filepath.Join(tmpDir, "video.mp4")
+ _ = os.WriteFile(videoPath, []byte("fake"), 0o644)
+ set := &model.Set{ID: 1, RootPath: "music"}
+ media := []model.Media{{ID: 1, SetID: 1, AbsPath: videoPath, Type: model.MediaTypeVideo}}
+ store := makeStore(1, media, set)
+ prober := &mockProber{ProbeFunc: func(ctx context.Context, path string) (*model.Metadata, error) {
+ return nil, errors.New("probe err")
+ }}
+ svc := NewMediaService(store, newMockClock(), tmpDir, nil, prober)
+ err := svc.RegenerateSetCover(ctx, 1, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ })
+
+ t.Run("thumb generation failure", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ videoPath := filepath.Join(tmpDir, "video.mp4")
+ _ = os.WriteFile(videoPath, []byte("fake"), 0o644)
+ set := &model.Set{ID: 1, RootPath: "music"}
+ media := []model.Media{{ID: 1, SetID: 1, AbsPath: videoPath, Type: model.MediaTypeVideo}}
+ store := makeStore(1, media, set)
+ thumbGen := &mockThumbGenerator{GenerateFunc: func(ctx context.Context, inputPath, outputPath string, duration float64) error {
+ return errors.New("thumb err")
+ }}
+ prober := &mockProber{ProbeFunc: func(ctx context.Context, path string) (*model.Metadata, error) {
+ return &model.Metadata{Duration: 60}, nil
+ }}
+ svc := NewMediaService(store, newMockClock(), tmpDir, thumbGen, prober)
+ err := svc.RegenerateSetCover(ctx, 1, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ })
+
+ t.Run("update set failure", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ videoPath := filepath.Join(tmpDir, "video.mp4")
+ _ = os.WriteFile(videoPath, []byte("fake"), 0o644)
+ set := &model.Set{ID: 1, RootPath: "music"}
+ media := []model.Media{{ID: 1, SetID: 1, AbsPath: videoPath, Type: model.MediaTypeVideo}}
+ store := makeStore(1, media, set)
+ store.SetRepo.UpdateSetFunc = func(ctx context.Context, s *model.Set) error {
+ return errors.New("update err")
+ }
+ thumbGen := &mockThumbGenerator{}
+ prober := &mockProber{ProbeFunc: func(ctx context.Context, path string) (*model.Metadata, error) {
+ return &model.Metadata{Duration: 60}, nil
+ }}
+ svc := NewMediaService(store, newMockClock(), tmpDir, thumbGen, prober)
+ err := svc.RegenerateSetCover(ctx, 1, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ })
+}
diff --git a/internal/service/no_rows_test.go b/internal/service/no_rows_test.go
index 9200668..b30e8f3 100644
--- a/internal/service/no_rows_test.go
+++ b/internal/service/no_rows_test.go
@@ -20,7 +20,7 @@ func TestService_NoRows_ReturnsNil(t *testing.T) {
},
},
}
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
detail, err := svc.GetMediaDetail(ctx, 99, 1)
if !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
@@ -53,7 +53,7 @@ func TestService_NoRows_ReturnsNil(t *testing.T) {
},
},
}
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
note, err := svc.GetNote(ctx, 1, 1)
if err != nil {
t.Fatalf("expected no error, got %v", err)
@@ -92,7 +92,7 @@ func TestService_NoRows_ReturnsNil(t *testing.T) {
},
},
}
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
if err := svc.AssignTag(ctx, 1, 1, "newtag"); err != nil {
t.Fatalf("expected no error, got %v", err)
}
@@ -133,7 +133,7 @@ func TestService_NoRows_ReturnsNil(t *testing.T) {
},
},
}
- svc := NewMediaService(store, newMockClock(), "/tmp/media")
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
sh, err := svc.ValidateShareToken(ctx, "nope")
if !errors.Is(err, ErrShareNotFound) {
t.Fatalf("expected ErrShareNotFound, got %v", err)
@@ -151,7 +151,7 @@ func TestService_NoRows_ReturnsNil(t *testing.T) {
},
}