diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-03 22:58:04 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-03 22:58:04 +0300 |
| commit | 003aee269bc73d5a0524c603585c567c9987916b (patch) | |
| tree | 557d81496d1b3dac3bd0f4d533b1fc5f35c83687 /internal/service/write.go | |
| parent | 600f50dac5f33eac14bfbcd15addaf210a260879 (diff) | |
task 2: decompose mediaService and adminService into role-focused structs
Extract accessHelper and split mediaService into:
- browseService (read-only browsing, streaming, thumbnails)
- writeService (upload, soft-delete, restore)
- shareService (share links)
- tagService (tagging)
- favService (favorites)
- noteService (notes)
Split adminService into:
- trashService (list deleted media)
- scanService (trigger rescan, scan progress)
- userAdminService (create/list/delete users)
- permissionAdminService (grant/revoke/list permissions)
Add repository sub-interfaces for each service.
Add negative tests for share sub-service.
Diffstat (limited to 'internal/service/write.go')
| -rw-r--r-- | internal/service/write.go | 213 |
1 files changed, 213 insertions, 0 deletions
diff --git a/internal/service/write.go b/internal/service/write.go new file mode 100644 index 0000000..af0c839 --- /dev/null +++ b/internal/service/write.go @@ -0,0 +1,213 @@ +package service + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "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" +) + +// writeService handles mutations such as upload, soft-delete and restore. +type writeService struct { + store repository.WriteServiceStore + clock clock.Clock + mediaRoot string + thumbGen thumb.Generator + prober probe.Prober + helper *accessHelper +} + +// NewWriteService creates a WriteService. +func NewWriteService(store repository.WriteServiceStore, clk clock.Clock, mediaRoot string, thumbGen thumb.Generator, prober probe.Prober, helper *accessHelper) MediaWriteService { + return &writeService{ + store: store, + clock: clk, + mediaRoot: mediaRoot, + thumbGen: thumbGen, + prober: prober, + helper: helper, + } +} + +func (s *writeService) SoftDeleteMedia(ctx context.Context, mediaID, userID int64) error { + _, err := s.helper.verifyModifyAccess(ctx, mediaID, userID) + if err != nil { + return err + } + return s.store.SoftDeleteMedia(ctx, mediaID) +} + +func (s *writeService) RestoreMedia(ctx context.Context, mediaID, userID int64) error { + _, err := s.helper.verifyModifyAccess(ctx, mediaID, userID) + if err != nil { + return err + } + return s.store.RestoreMedia(ctx, mediaID) +} + +func (s *writeService) UploadMedia(ctx context.Context, setID, userID int64, filename string, data io.Reader, size int64) (*model.Media, error) { + set, err := s.store.GetSetByID(ctx, setID) + if err != nil { + return nil, fmt.Errorf("get set: %w", err) + } + if set == nil { + return nil, ErrNotFound + } + + if err := s.helper.verifySetModifyAccess(ctx, setID, userID); err != nil { + return nil, err + } + + if !isSupportedExtension(filename) { + return nil, fmt.Errorf("%w: %s", ErrUnsupportedExtension, filepath.Ext(filename)) + } + + dir := filepath.Clean(filepath.Join(s.mediaRoot, set.RootPath)) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("mkdir: %w", err) + } + + path := s.uniqueFilename(dir, filename) + if !strings.HasPrefix(filepath.Clean(path), filepath.Clean(dir)+string(filepath.Separator)) { + return nil, errors.New("invalid filename") + } + + media, err := s.saveUploadedMedia(ctx, setID, path, data, size) + if err != nil { + os.Remove(path) + return nil, err + } + + meta, err := s.probeMedia(ctx, path) + if err != nil { + os.Remove(path) + s.store.HardDeleteMedia(ctx, media.ID) + return nil, err + } + media.Duration = meta.Duration + media.Codec = meta.Codec + media.Resolution = meta.Resolution + media.Bitrate = meta.Bitrate + media.Width = meta.Width + media.Height = meta.Height + media.EXIFCamera = meta.EXIFCamera + media.EXIFLens = meta.EXIFLens + media.EXIFDate = meta.EXIFDate + media.EXIFISO = meta.EXIFISO + media.EXIFFNumber = meta.EXIFFNumber + media.EXIFExposure = meta.EXIFExposure + media.EXIFFocalLength = meta.EXIFFocalLength + + if media.Type == model.MediaTypeVideo || media.Type == model.MediaTypeImage { + if err := s.generateThumbnail(ctx, media, meta.Duration); err != nil { + os.Remove(path) + _ = s.store.HardDeleteMedia(ctx, media.ID) + return nil, err + } + } + + if err := s.store.UpdateMedia(ctx, media); err != nil { + os.Remove(path) + _ = s.store.HardDeleteMedia(ctx, media.ID) + return nil, fmt.Errorf("update media metadata: %w", err) + } + + return media, nil +} + +func (s *writeService) uniqueFilename(dir, filename string) string { + filename = filepath.Base(filename) + if filename == "." || filename == ".." || filename == "" { + return "" + } + ext := filepath.Ext(filename) + base := strings.TrimSuffix(filename, ext) + + candidate := filepath.Join(dir, filename) + if _, err := os.Stat(candidate); os.IsNotExist(err) { + return candidate + } + + for i := 1; ; i++ { + candidate = filepath.Join(dir, fmt.Sprintf("%s(%d)%s", base, i, ext)) + if _, err := os.Stat(candidate); os.IsNotExist(err) { + return candidate + } + } +} + +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 { + return nil, fmt.Errorf("create file: %w", err) + } + defer f.Close() + + n, err := io.Copy(f, data) + if err != nil { + return nil, fmt.Errorf("write file: %w", err) + } + + now := s.clock.Now() + media := &model.Media{ + SetID: setID, + RelPath: filepath.Base(path), + FileName: filepath.Base(path), + AbsPath: path, + Type: guessMediaType(filepath.Base(path)), + FileSizeBytes: n, + CreatedAt: now, + } + _ = size + + id, err := s.store.CreateMedia(ctx, media) + if err != nil { + return nil, fmt.Errorf("create media: %w", err) + } + media.ID = id + return media, nil +} + +func (s *writeService) probeMedia(ctx context.Context, path string) (*model.Metadata, error) { + if s.prober == nil { + return &model.Metadata{}, nil + } + meta, err := s.prober.Probe(ctx, path) + if err != nil { + return nil, fmt.Errorf("probe media: %w", err) + } + return meta, nil +} + +func (s *writeService) generateThumbnail(ctx context.Context, media *model.Media, duration float64) error { + ext := strings.ToLower(filepath.Ext(media.AbsPath)) + if ext == ".svg" { + media.ThumbnailPath = media.AbsPath + return nil + } + 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 s.thumbGen == nil { + media.ThumbnailPath = thumbnailPath + return nil + } + if err := s.thumbGen.Generate(ctx, media.AbsPath, thumbnailPath, duration); err != nil { + return fmt.Errorf("generate thumbnail: %w", err) + } + media.ThumbnailPath = thumbnailPath + return nil +} |
