diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/api/handlers_file.go | 4 | ||||
| -rw-r--r-- | internal/api/handlers_media.go | 4 | ||||
| -rw-r--r-- | internal/service/browse.go | 106 | ||||
| -rw-r--r-- | internal/service/browse_test.go | 26 | ||||
| -rw-r--r-- | internal/service/media.go | 2 | ||||
| -rw-r--r-- | internal/service/service.go | 16 | ||||
| -rw-r--r-- | internal/service/write.go | 99 |
7 files changed, 126 insertions, 131 deletions
diff --git a/internal/api/handlers_file.go b/internal/api/handlers_file.go index 3228962..0638603 100644 --- a/internal/api/handlers_file.go +++ b/internal/api/handlers_file.go @@ -85,7 +85,7 @@ func (s *Server) handleThumbnail(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleRegenThumbnail(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.browseSvc) { + if !requireService(w, s.writeSvc) { return } id := pathID(r, "id") @@ -93,7 +93,7 @@ func (s *Server) handleRegenThumbnail(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid media id"}) return } - if err := s.browseSvc.RegenerateThumbnail(r.Context(), id, userIDFromContext(r)); err != nil { + if err := s.writeSvc.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 diff --git a/internal/api/handlers_media.go b/internal/api/handlers_media.go index c761bce..ee74d38 100644 --- a/internal/api/handlers_media.go +++ b/internal/api/handlers_media.go @@ -56,7 +56,7 @@ func (s *Server) handleGetSetCover(w http.ResponseWriter, r *http.Request) { } func (s *Server) handlePostSetCover(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.browseSvc) { + if !requireService(w, s.writeSvc) { return } setID := pathID(r, "id") @@ -65,7 +65,7 @@ func (s *Server) handlePostSetCover(w http.ResponseWriter, r *http.Request) { return } folder := r.URL.Query().Get("folder") - if err := s.browseSvc.RegenerateSetCover(r.Context(), setID, folder, userIDFromContext(r)); err != nil { + if err := s.writeSvc.RegenerateSetCover(r.Context(), setID, folder, userIDFromContext(r)); err != nil { if errors.Is(err, service.ErrNotFound) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) return diff --git a/internal/service/browse.go b/internal/service/browse.go index 0df399b..7425d35 100644 --- a/internal/service/browse.go +++ b/internal/service/browse.go @@ -12,9 +12,7 @@ import ( "codeberg.org/snonux/player/internal/clock" "codeberg.org/snonux/player/internal/model" - "codeberg.org/snonux/player/internal/probe" "codeberg.org/snonux/player/internal/repository" - "codeberg.org/snonux/player/internal/thumb" ) // browseService handles read-only browsing and media streaming operations. @@ -22,19 +20,15 @@ type browseService struct { store repository.BrowseServiceStore clock clock.Clock mediaRoot string - thumbGen thumb.Generator - prober probe.Prober helper *accessHelper } // NewBrowseService creates a BrowseService. -func NewBrowseService(store repository.BrowseServiceStore, clk clock.Clock, mediaRoot string, thumbGen thumb.Generator, prober probe.Prober, helper *accessHelper) MediaBrowseService { +func NewBrowseService(store repository.BrowseServiceStore, clk clock.Clock, mediaRoot string, helper *accessHelper) MediaBrowseService { return &browseService{ store: store, clock: clk, mediaRoot: mediaRoot, - thumbGen: thumbGen, - prober: prober, helper: helper, } } @@ -205,104 +199,6 @@ func (s *browseService) GetThumbnail(ctx context.Context, mediaID, userID int64) return nil, fmt.Errorf("stat thumbnail: %w", err) } -func (s *browseService) RegenerateThumbnail(ctx context.Context, mediaID, userID int64) error { - media, err := s.helper.verifyModifyAccess(ctx, mediaID, userID) - if err != nil { - return err - } - if media.Type != model.MediaTypeVideo && media.Type != model.MediaTypeImage { - return errors.New("thumbnails can only be generated for video and image 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 *browseService) RegenerateSetCover(ctx context.Context, setID int64, folder string, userID int64) error { - if err := s.helper.verifySetModifyAccess(ctx, setID, userID); err != nil { - return err - } - - 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) - } - - prefix := filepath.ToSlash(strings.Trim(folder, "/")) - var candidates []model.Media - for _, m := range media { - if m.DeletedAt != nil { - continue - } - rel := filepath.ToSlash(m.RelPath) - if prefix != "" { - if !strings.HasPrefix(rel, prefix+"/") { - continue - } - suffix := strings.TrimPrefix(rel, prefix+"/") - if strings.Contains(suffix, "/") { - continue - } - } else if strings.Contains(rel, "/") { - continue - } - if m.Type == model.MediaTypeVideo { - 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))] - } - - baseDir := filepath.Join(s.mediaRoot, filepath.FromSlash(set.RootPath)) - if prefix != "" { - baseDir = filepath.Join(baseDir, filepath.FromSlash(prefix)) - } - coverPath := filepath.Join(filepath.Clean(baseDir), ".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) - } - - return nil -} - // prefixForParent builds the slash-terminated prefix used for matching paths under parent. func prefixForParent(parent string) string { if parent == "" { diff --git a/internal/service/browse_test.go b/internal/service/browse_test.go index 7a05c9f..479d05e 100644 --- a/internal/service/browse_test.go +++ b/internal/service/browse_test.go @@ -15,14 +15,14 @@ func TestBrowseService_BrowseSet(t *testing.T) { tmpDir := t.TempDir() tests := []struct { - name string - set *model.Set - setErr error - media []model.Media - mediaErr error - wantErr bool - wantMedia int - wantFolders int + name string + set *model.Set + setErr error + media []model.Media + mediaErr error + wantErr bool + wantMedia int + wantFolders int }{ { name: "ok empty set", @@ -39,10 +39,10 @@ func TestBrowseService_BrowseSet(t *testing.T) { wantFolders: 0, }, { - name: "list media error", - set: &model.Set{ID: 1, RootPath: "music"}, - mediaErr: errors.New("boom"), - wantErr: true, + name: "list media error", + set: &model.Set{ID: 1, RootPath: "music"}, + mediaErr: errors.New("boom"), + wantErr: true, }, { name: "set not found", @@ -76,7 +76,7 @@ func TestBrowseService_BrowseSet(t *testing.T) { }, }, } - svc := NewBrowseService(store, clock.RealClock{}, tmpDir, nil, nil, &accessHelper{store: store}) + svc := NewBrowseService(store, clock.RealClock{}, tmpDir, &accessHelper{store: store}) res, err := svc.BrowseSet(ctx, 1, 1, "") if tt.wantErr { if err == nil { diff --git a/internal/service/media.go b/internal/service/media.go index 6dbd5af..893f1a3 100644 --- a/internal/service/media.go +++ b/internal/service/media.go @@ -32,7 +32,7 @@ type mediaService struct { func NewMediaService(store repository.MediaServiceStore, clk clock.Clock, mediaRoot string, thumbGen thumb.Generator, prober probe.Prober) MediaService { helper := &accessHelper{store: store} return &mediaService{ - MediaBrowseService: NewBrowseService(store, clk, mediaRoot, thumbGen, prober, helper), + MediaBrowseService: NewBrowseService(store, clk, mediaRoot, helper), MediaWriteService: NewWriteService(store, clk, mediaRoot, thumbGen, prober, helper), MediaShareService: NewShareService(store, clk, helper), MediaTagService: NewTagService(store, helper), diff --git a/internal/service/service.go b/internal/service/service.go index 6a5f6e6..16cd5f1 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -54,10 +54,6 @@ type MediaBrowseService interface { DownloadMedia(ctx context.Context, mediaID, userID int64) (*FileResult, error) // GetThumbnail returns a media thumbnail for an authorized user. GetThumbnail(ctx context.Context, mediaID, userID int64) (*FileResult, error) - // RegenerateThumbnail refreshes a media item's thumbnail. - RegenerateThumbnail(ctx context.Context, mediaID, userID int64) error - // RegenerateSetCover refreshes a folder cover image for a set. - RegenerateSetCover(ctx context.Context, setID int64, folder string, userID int64) error // BrowseSet returns folders and media below a set path. BrowseSet(ctx context.Context, setID, userID int64, parent string) (*BrowseResult, error) // GetSetCover returns the cover image for a set folder. @@ -72,6 +68,10 @@ type MediaWriteService interface { RestoreMedia(ctx context.Context, mediaID, userID int64) error // UploadMedia stores an uploaded media file in a set. UploadMedia(ctx context.Context, setID, userID int64, filename string, data io.Reader, size int64) (*model.Media, error) + // RegenerateThumbnail refreshes a media item's thumbnail. + RegenerateThumbnail(ctx context.Context, mediaID, userID int64) error + // RegenerateSetCover refreshes a folder cover image for a set. + RegenerateSetCover(ctx context.Context, setID int64, folder string, userID int64) error } // BrowseFolder is a named folder within a set's directory tree. @@ -82,10 +82,10 @@ type BrowseFolder struct { // BrowseResult is the content of one directory inside a set. type BrowseResult struct { - CurrentPath string `json:"current_path"` - Folders []BrowseFolder `json:"folders"` - Media []model.Media `json:"media"` - Episodes []model.PodcastEpisodeWithStatus `json:"episodes,omitempty"` + CurrentPath string `json:"current_path"` + Folders []BrowseFolder `json:"folders"` + Media []model.Media `json:"media"` + Episodes []model.PodcastEpisodeWithStatus `json:"episodes,omitempty"` } // SharedMediaView exposes only the metadata fields needed for a public share page. diff --git a/internal/service/write.go b/internal/service/write.go index a83c7e1..b67e644 100644 --- a/internal/service/write.go +++ b/internal/service/write.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + mrand "math/rand" "os" "path/filepath" "strings" @@ -97,6 +98,104 @@ func (s *writeService) UploadMedia(ctx context.Context, setID, userID int64, fil return media, nil } +func (s *writeService) RegenerateThumbnail(ctx context.Context, mediaID, userID int64) error { + media, err := s.helper.verifyModifyAccess(ctx, mediaID, userID) + if err != nil { + return err + } + if media.Type != model.MediaTypeVideo && media.Type != model.MediaTypeImage { + return errors.New("thumbnails can only be generated for video and image 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 *writeService) RegenerateSetCover(ctx context.Context, setID int64, folder string, userID int64) error { + if err := s.helper.verifySetModifyAccess(ctx, setID, userID); err != nil { + return err + } + + 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) + } + + prefix := filepath.ToSlash(strings.Trim(folder, "/")) + var candidates []model.Media + for _, m := range media { + if m.DeletedAt != nil { + continue + } + rel := filepath.ToSlash(m.RelPath) + if prefix != "" { + if !strings.HasPrefix(rel, prefix+"/") { + continue + } + suffix := strings.TrimPrefix(rel, prefix+"/") + if strings.Contains(suffix, "/") { + continue + } + } else if strings.Contains(rel, "/") { + continue + } + if m.Type == model.MediaTypeVideo { + 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))] + } + + baseDir := filepath.Join(s.mediaRoot, filepath.FromSlash(set.RootPath)) + if prefix != "" { + baseDir = filepath.Join(baseDir, filepath.FromSlash(prefix)) + } + coverPath := filepath.Join(filepath.Clean(baseDir), ".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) + } + + return nil +} + func (s *writeService) saveUploadedMedia(ctx context.Context, setID int64, path string, data io.Reader, size int64) (*model.Media, error) { f, err := os.Create(path) if err != nil { |
