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 | |
| 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')
| -rw-r--r-- | internal/service/access.go (renamed from internal/service/media_access.go) | 30 | ||||
| -rw-r--r-- | internal/service/admin.go | 135 | ||||
| -rw-r--r-- | internal/service/browse.go (renamed from internal/service/media_browse.go) | 204 | ||||
| -rw-r--r-- | internal/service/fav.go | 28 | ||||
| -rw-r--r-- | internal/service/media.go | 119 | ||||
| -rw-r--r-- | internal/service/media_notes.go | 29 | ||||
| -rw-r--r-- | internal/service/note.go | 47 | ||||
| -rw-r--r-- | internal/service/perm.go | 62 | ||||
| -rw-r--r-- | internal/service/scan.go | 75 | ||||
| -rw-r--r-- | internal/service/service.go | 63 | ||||
| -rw-r--r-- | internal/service/share.go (renamed from internal/service/media_share.go) | 40 | ||||
| -rw-r--r-- | internal/service/share_test.go | 118 | ||||
| -rw-r--r-- | internal/service/tag.go | 55 | ||||
| -rw-r--r-- | internal/service/trash.go | 22 | ||||
| -rw-r--r-- | internal/service/user.go | 52 | ||||
| -rw-r--r-- | internal/service/write.go (renamed from internal/service/media_write.go) | 110 |
16 files changed, 781 insertions, 408 deletions
diff --git a/internal/service/media_access.go b/internal/service/access.go index f0a9ddb..891fd98 100644 --- a/internal/service/media_access.go +++ b/internal/service/access.go @@ -5,12 +5,18 @@ import ( "fmt" "codeberg.org/snonux/player/internal/model" + "codeberg.org/snonux/player/internal/repository" ) +// accessHelper encapsulates permission checks used by media sub-services. +type accessHelper struct { + store repository.MediaServiceStore +} + // checkSetPermission verifies that a user has the required role on a set. // An empty requiredRole means any role is accepted. Admins are always allowed. -func (s *mediaService) checkSetPermission(ctx context.Context, setID, userID int64, requiredRole model.Role) error { - user, err := s.store.GetUserByID(ctx, userID) +func (h *accessHelper) checkSetPermission(ctx context.Context, setID, userID int64, requiredRole model.Role) error { + user, err := h.store.GetUserByID(ctx, userID) if err != nil { return fmt.Errorf("get user: %w", err) } @@ -18,7 +24,7 @@ func (s *mediaService) checkSetPermission(ctx context.Context, setID, userID int return nil } - perm, err := s.store.GetPermission(ctx, setID, userID) + perm, err := h.store.GetPermission(ctx, setID, userID) if err != nil { return fmt.Errorf("get permission: %w", err) } @@ -26,7 +32,7 @@ func (s *mediaService) checkSetPermission(ctx context.Context, setID, userID int return nil } - set, err := s.store.GetSetByID(ctx, setID) + set, err := h.store.GetSetByID(ctx, setID) if err != nil { return fmt.Errorf("get set: %w", err) } @@ -41,8 +47,8 @@ func (s *mediaService) checkSetPermission(ctx context.Context, setID, userID int return ErrForbidden } -func (s *mediaService) verifyAccess(ctx context.Context, mediaID, userID int64) (*model.Media, error) { - media, err := s.store.GetMediaByID(ctx, mediaID) +func (h *accessHelper) verifyAccess(ctx context.Context, mediaID, userID int64) (*model.Media, error) { + media, err := h.store.GetMediaByID(ctx, mediaID) if err != nil { return nil, fmt.Errorf("get media: %w", err) } @@ -50,7 +56,7 @@ func (s *mediaService) verifyAccess(ctx context.Context, mediaID, userID int64) return nil, ErrNotFound } - if err := s.checkSetPermission(ctx, media.SetID, userID, ""); err != nil { + if err := h.checkSetPermission(ctx, media.SetID, userID, ""); err != nil { return nil, err } @@ -58,13 +64,13 @@ func (s *mediaService) verifyAccess(ctx context.Context, mediaID, userID int64) } // verifyModifyAccess checks that the user has access to the media and is an owner or admin. -func (s *mediaService) verifyModifyAccess(ctx context.Context, mediaID, userID int64) (*model.Media, error) { - media, err := s.verifyAccess(ctx, mediaID, userID) +func (h *accessHelper) verifyModifyAccess(ctx context.Context, mediaID, userID int64) (*model.Media, error) { + media, err := h.verifyAccess(ctx, mediaID, userID) if err != nil { return nil, err } - if err := s.checkSetPermission(ctx, media.SetID, userID, model.RoleOwner); err != nil { + if err := h.checkSetPermission(ctx, media.SetID, userID, model.RoleOwner); err != nil { return nil, err } @@ -72,6 +78,6 @@ func (s *mediaService) verifyModifyAccess(ctx context.Context, mediaID, userID i } // verifySetModifyAccess checks that the user is an owner or admin for a set. -func (s *mediaService) verifySetModifyAccess(ctx context.Context, setID, userID int64) error { - return s.checkSetPermission(ctx, setID, userID, model.RoleOwner) +func (h *accessHelper) verifySetModifyAccess(ctx context.Context, setID, userID int64) error { + return h.checkSetPermission(ctx, setID, userID, model.RoleOwner) } diff --git a/internal/service/admin.go b/internal/service/admin.go index bd745af..1354005 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -2,10 +2,7 @@ package service import ( "context" - "fmt" "log/slog" - "sync" - "time" "codeberg.org/snonux/player/internal/auth" "codeberg.org/snonux/player/internal/clock" @@ -15,16 +12,12 @@ import ( ) // adminService is the concrete implementation of AdminService. +// It composes role-focused sub-services to satisfy SRP. type adminService struct { - store repository.AdminServiceStore - clock clock.Clock - hasher auth.Hasher - scanner scanner.Scanner - mediaRoot string - logger *slog.Logger - mu sync.Mutex - scanCancel context.CancelFunc - progress *model.ScanProgress + *trashService + *scanService + *userAdminService + *permissionAdminService } // NewAdminService creates a concrete AdminService. @@ -34,129 +27,55 @@ func NewAdminService(store repository.AdminServiceStore, clk clock.Clock, hasher // NewAdminServiceWithLogger creates a concrete AdminService with an injected logger. func NewAdminServiceWithLogger(store repository.AdminServiceStore, clk clock.Clock, hasher auth.Hasher, sc scanner.Scanner, mediaRoot string, logger *slog.Logger) AdminService { - if logger == nil { - logger = slog.Default() - } return &adminService{ - store: store, - clock: clk, - hasher: hasher, - scanner: sc, - mediaRoot: mediaRoot, - logger: logger, + trashService: NewTrashService(store), + scanService: NewScanService(sc, mediaRoot, clk, logger), + userAdminService: NewUserAdminService(store, clk, hasher), + permissionAdminService: NewPermissionAdminService(store, clk), } } +// ListTrash delegates to trashService. func (s *adminService) ListTrash(ctx context.Context) ([]model.Media, error) { - return s.store.ListDeletedMedia(ctx) + return s.trashService.ListTrash(ctx) } +// TriggerRescan delegates to scanService. func (s *adminService) TriggerRescan(ctx context.Context) error { - if s.scanner == nil { - return fmt.Errorf("scanner not configured") - } - - s.mu.Lock() - if s.scanCancel != nil { - s.scanCancel() - } - scanCtx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) - s.scanCancel = cancel - progress := &model.ScanProgress{} - s.progress = progress - s.mu.Unlock() - - // Run the scan in a background goroutine so the HTTP request - // returns immediately and the scan continues asynchronously. - go func() { - defer cancel() - if err := s.scanner.Scan(scanCtx, s.mediaRoot, progress); err != nil { - progress.Done(err) - s.logger.Error("rescan failed", "err", err) - } else { - progress.Done(nil) - s.logger.Info("rescan completed") - } - }() - return nil + return s.scanService.TriggerRescan(ctx) } +// ScanProgress delegates to scanService. func (s *adminService) ScanProgress(ctx context.Context) model.ScanProgress { - s.mu.Lock() - progress := s.progress - s.mu.Unlock() - if progress == nil { - return model.ScanProgress{} - } - return progress.Copy() + return s.scanService.ScanProgress(ctx) } +// ListUsers delegates to userAdminService. func (s *adminService) ListUsers(ctx context.Context) ([]model.User, error) { - return s.store.ListUsers(ctx) + return s.userAdminService.ListUsers(ctx) } +// CreateUser delegates to userAdminService. func (s *adminService) CreateUser(ctx context.Context, username, password string, isAdmin bool) (*model.User, error) { - hash, err := s.hasher.Hash(password) - if err != nil { - return nil, fmt.Errorf("hash password: %w", err) - } - - user := &model.User{ - Username: username, - PasswordHash: hash, - IsAdmin: isAdmin, - CreatedAt: s.clock.Now(), - } - - id, err := s.store.CreateUser(ctx, user) - if err != nil { - return nil, fmt.Errorf("create user: %w", err) - } - user.ID = id - return user, nil + return s.userAdminService.CreateUser(ctx, username, password, isAdmin) } +// DeleteUser delegates to userAdminService. func (s *adminService) DeleteUser(ctx context.Context, id int64) error { - return s.store.DeleteUser(ctx, id) + return s.userAdminService.DeleteUser(ctx, id) } +// ListPermissions delegates to permissionAdminService. func (s *adminService) ListPermissions(ctx context.Context) (*PermissionsMatrix, error) { - sets, err := s.store.ListSets(ctx) - if err != nil { - return nil, fmt.Errorf("list sets: %w", err) - } - - users, err := s.store.ListUsers(ctx) - if err != nil { - return nil, fmt.Errorf("list users: %w", err) - } - - var perms []model.SetPermission - for _, set := range sets { - setPerms, err := s.store.ListPermissionsBySet(ctx, set.ID) - if err != nil { - return nil, fmt.Errorf("list permissions by set: %w", err) - } - perms = append(perms, setPerms...) - } - - return &PermissionsMatrix{ - Sets: sets, - Users: users, - Permissions: perms, - }, nil + return s.permissionAdminService.ListPermissions(ctx) } +// GrantPermission delegates to permissionAdminService. func (s *adminService) GrantPermission(ctx context.Context, setID, userID int64, role model.Role) error { - perm := &model.SetPermission{ - SetID: setID, - UserID: userID, - Role: role, - CreatedAt: s.clock.Now(), - } - return s.store.GrantPermission(ctx, perm) + return s.permissionAdminService.GrantPermission(ctx, setID, userID, role) } +// RevokePermission delegates to permissionAdminService. func (s *adminService) RevokePermission(ctx context.Context, setID, userID int64) error { - return s.store.RevokePermission(ctx, setID, userID) + return s.permissionAdminService.RevokePermission(ctx, setID, userID) } diff --git a/internal/service/media_browse.go b/internal/service/browse.go index 6635688..9e2f42c 100644 --- a/internal/service/media_browse.go +++ b/internal/service/browse.go @@ -10,11 +10,36 @@ import ( "sort" "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" ) -func (s *mediaService) ListSets(ctx context.Context, userID int64) ([]model.Set, error) { +// browseService handles read-only browsing and media streaming operations. +type browseService struct { + store repository.MediaServiceStore + clock clock.Clock + mediaRoot string + thumbGen thumb.Generator + prober probe.Prober + helper *accessHelper +} + +// NewBrowseService creates a BrowseService. +func NewBrowseService(store repository.MediaServiceStore, clk clock.Clock, mediaRoot string, thumbGen thumb.Generator, prober probe.Prober, helper *accessHelper) MediaBrowseService { + return &browseService{ + store: store, + clock: clk, + mediaRoot: mediaRoot, + thumbGen: thumbGen, + prober: prober, + helper: helper, + } +} + +func (s *browseService) ListSets(ctx context.Context, userID int64) ([]model.Set, error) { user, err := s.store.GetUserByID(ctx, userID) if err != nil { return nil, fmt.Errorf("get user: %w", err) @@ -56,8 +81,8 @@ func (s *mediaService) ListSets(ctx context.Context, userID int64) ([]model.Set, return filtered, nil } -func (s *mediaService) GetMediaDetail(ctx context.Context, mediaID, userID int64) (*MediaDetail, error) { - media, err := s.verifyAccess(ctx, mediaID, userID) +func (s *browseService) GetMediaDetail(ctx context.Context, mediaID, userID int64) (*MediaDetail, error) { + media, err := s.helper.verifyAccess(ctx, mediaID, userID) if err != nil { return nil, err } @@ -91,7 +116,7 @@ func (s *mediaService) GetMediaDetail(ctx context.Context, mediaID, userID int64 }, nil } -func (s *mediaService) ListMedia(ctx context.Context, userID int64, filter repository.MediaFilter) ([]model.Media, error) { +func (s *browseService) ListMedia(ctx context.Context, userID int64, filter repository.MediaFilter) ([]model.Media, error) { user, err := s.store.GetUserByID(ctx, userID) if err != nil { return nil, fmt.Errorf("get user: %w", err) @@ -116,8 +141,8 @@ func (s *mediaService) ListMedia(ctx context.Context, userID int64, filter repos return s.store.ListMedia(ctx, filter) } -func (s *mediaService) StreamMedia(ctx context.Context, mediaID, userID int64) (*FileResult, error) { - media, err := s.verifyAccess(ctx, mediaID, userID) +func (s *browseService) StreamMedia(ctx context.Context, mediaID, userID int64) (*FileResult, error) { + media, err := s.helper.verifyAccess(ctx, mediaID, userID) if err != nil { return nil, err } @@ -128,12 +153,12 @@ func (s *mediaService) StreamMedia(ctx context.Context, mediaID, userID int64) ( }, nil } -func (s *mediaService) DownloadMedia(ctx context.Context, mediaID, userID int64) (*FileResult, error) { +func (s *browseService) DownloadMedia(ctx context.Context, mediaID, userID int64) (*FileResult, error) { return s.StreamMedia(ctx, mediaID, userID) } -func (s *mediaService) GetThumbnail(ctx context.Context, mediaID, userID int64) (*FileResult, error) { - media, err := s.verifyAccess(ctx, mediaID, userID) +func (s *browseService) GetThumbnail(ctx context.Context, mediaID, userID int64) (*FileResult, error) { + media, err := s.helper.verifyAccess(ctx, mediaID, userID) if err != nil { return nil, err } @@ -151,8 +176,8 @@ func (s *mediaService) GetThumbnail(ctx context.Context, mediaID, userID int64) }, nil } -func (s *mediaService) RegenerateThumbnail(ctx context.Context, mediaID, userID int64) error { - media, err := s.verifyModifyAccess(ctx, mediaID, userID) +func (s *browseService) RegenerateThumbnail(ctx context.Context, mediaID, userID int64) error { + media, err := s.helper.verifyModifyAccess(ctx, mediaID, userID) if err != nil { return err } @@ -183,8 +208,8 @@ func (s *mediaService) RegenerateThumbnail(ctx context.Context, mediaID, userID return nil } -func (s *mediaService) RegenerateSetCover(ctx context.Context, setID int64, folder string, userID int64) error { - if err := s.verifySetModifyAccess(ctx, setID, userID); err != 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 } @@ -249,103 +274,8 @@ func (s *mediaService) RegenerateSetCover(ctx context.Context, setID int64, fold return nil } -func (s *mediaService) GetSetCover(ctx context.Context, setID int64, folder string, userID int64) (*FileResult, error) { - if err := s.checkSetPermission(ctx, setID, userID, ""); err != nil { - return nil, err - } - - set, err := s.store.GetSetByID(ctx, setID) - if err != nil { - return nil, fmt.Errorf("get set: %w", err) - } - if set == nil { - return nil, ErrNotFound - } - - prefix := filepath.ToSlash(strings.Trim(folder, "/")) - 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") - - info, err := os.Stat(coverPath) - if err == nil { - return &FileResult{ - Path: coverPath, - FileName: filepath.Base(coverPath), - FileSize: info.Size(), - }, nil - } - if fr, ok := folderCoverFile(baseDir); ok { - return fr, nil - } - - media, err := s.store.ListMedia(ctx, repository.MediaFilter{SetID: &setID}) - if err != nil { - return nil, fmt.Errorf("list media: %w", err) - } - candidate := randomFolderThumbnail(media, prefix) - if candidate == "" { - return nil, fmt.Errorf("stat cover: %w", err) - } - info, err = os.Stat(candidate) - if err != nil { - return nil, fmt.Errorf("stat thumbnail cover: %w", err) - } - return &FileResult{ - Path: candidate, - FileName: filepath.Base(candidate), - FileSize: info.Size(), - }, nil -} - -func (s *mediaService) ToggleFavorite(ctx context.Context, userID, mediaID int64) (bool, error) { - if _, err := s.verifyAccess(ctx, mediaID, userID); err != nil { - return false, err - } - return s.store.ToggleFavorite(ctx, userID, mediaID) -} - -func (s *mediaService) AssignTag(ctx context.Context, mediaID, userID int64, tagName string) error { - if _, err := s.verifyAccess(ctx, mediaID, userID); err != nil { - return err - } - tag, err := s.store.GetTagByName(ctx, tagName) - if err != nil { - return fmt.Errorf("get tag: %w", err) - } - if tag == nil { - id, err := s.store.CreateTag(ctx, tagName) - if err != nil { - return fmt.Errorf("create tag: %w", err) - } - tag = &model.Tag{ID: id, Name: tagName} - } - return s.store.AssignTag(ctx, mediaID, tag.ID) -} - -func (s *mediaService) RemoveTag(ctx context.Context, mediaID, userID int64, tagName string) error { - if _, err := s.verifyAccess(ctx, mediaID, userID); err != nil { - return err - } - tag, err := s.store.GetTagByName(ctx, tagName) - if err != nil { - return fmt.Errorf("get tag: %w", err) - } - if tag == nil { - return errors.New("tag not found") - } - return s.store.RemoveTag(ctx, mediaID, tag.ID) -} - -// BrowseSet returns the immediate subfolders and media files inside -// a specific folder (parent) of a set. -// If a subfolder contains exactly one file and no further subfolders, -// that file is "flattened" and shown at the current level instead of -// presenting the folder. -func (s *mediaService) BrowseSet(ctx context.Context, setID, userID int64, parent string) (*BrowseResult, error) { - if err := s.checkSetPermission(ctx, setID, userID, ""); err != nil { +func (s *browseService) BrowseSet(ctx context.Context, setID, userID int64, parent string) (*BrowseResult, error) { + if err := s.helper.checkSetPermission(ctx, setID, userID, ""); err != nil { return nil, err } @@ -389,11 +319,9 @@ func (s *mediaService) BrowseSet(ctx context.Context, setID, userID int64, paren parts := strings.SplitN(suffix, "/", 2) name := parts[0] if len(parts) == 1 { - // File at the current level. items = append(items, m) continue } - // Inside a subfolder — count what is in there. fc, ok := folderMap[name] if !ok { fc = &folderContent{subfolders: make(map[string]struct{})} @@ -412,7 +340,6 @@ func (s *mediaService) BrowseSet(ctx context.Context, setID, userID int64, paren for name, fc := range folderMap { total := len(fc.files) + len(fc.subfolders) if total == 1 && len(fc.files) == 1 { - // Flatten: show the lone file at the current level. items = append(items, fc.files[0]) } else { subPath := filepath.Join(parent, name) @@ -433,6 +360,57 @@ func (s *mediaService) BrowseSet(ctx context.Context, setID, userID int64, paren }, nil } +func (s *browseService) GetSetCover(ctx context.Context, setID int64, folder string, userID int64) (*FileResult, error) { + if err := s.helper.checkSetPermission(ctx, setID, userID, ""); err != nil { + return nil, err + } + + set, err := s.store.GetSetByID(ctx, setID) + if err != nil { + return nil, fmt.Errorf("get set: %w", err) + } + if set == nil { + return nil, ErrNotFound + } + + prefix := filepath.ToSlash(strings.Trim(folder, "/")) + 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") + + info, err := os.Stat(coverPath) + if err == nil { + return &FileResult{ + Path: coverPath, + FileName: filepath.Base(coverPath), + FileSize: info.Size(), + }, nil + } + if fr, ok := folderCoverFile(baseDir); ok { + return fr, nil + } + + media, err := s.store.ListMedia(ctx, repository.MediaFilter{SetID: &setID}) + if err != nil { + return nil, fmt.Errorf("list media: %w", err) + } + candidate := randomFolderThumbnail(media, prefix) + if candidate == "" { + return nil, fmt.Errorf("stat cover: %w", err) + } + info, err = os.Stat(candidate) + if err != nil { + return nil, fmt.Errorf("stat thumbnail cover: %w", err) + } + return &FileResult{ + Path: candidate, + FileName: filepath.Base(candidate), + FileSize: info.Size(), + }, nil +} + func randomFolderThumbnail(media []model.Media, folder string) string { prefix := filepath.ToSlash(strings.Trim(folder, "/")) if prefix != "" { diff --git a/internal/service/fav.go b/internal/service/fav.go new file mode 100644 index 0000000..57dcb73 --- /dev/null +++ b/internal/service/fav.go @@ -0,0 +1,28 @@ +package service + +import ( + "context" + + "codeberg.org/snonux/player/internal/repository" +) + +// favService handles toggling favorite status. +type favService struct { + store repository.FavoriteServiceStore + helper *accessHelper +} + +// NewFavService creates a FavService. +func NewFavService(store repository.FavoriteServiceStore, helper *accessHelper) MediaFavoriteService { + return &favService{ + store: store, + helper: helper, + } +} + +func (s *favService) ToggleFavorite(ctx context.Context, userID, mediaID int64) (bool, error) { + if _, err := s.helper.verifyAccess(ctx, mediaID, userID); err != nil { + return false, err + } + return s.store.ToggleFavorite(ctx, userID, mediaID) +} diff --git a/internal/service/media.go b/internal/service/media.go index 2f7719d..6dbd5af 100644 --- a/internal/service/media.go +++ b/internal/service/media.go @@ -1,15 +1,7 @@ package service import ( - "context" - "errors" - "fmt" - "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" @@ -26,106 +18,25 @@ var ( ) // mediaService is the concrete implementation of MediaService. +// It composes role-focused sub-services to satisfy SRP. type mediaService struct { - store repository.MediaServiceStore - clock clock.Clock - mediaRoot string - thumbGen thumb.Generator - prober probe.Prober + MediaBrowseService + MediaWriteService + MediaShareService + MediaTagService + MediaFavoriteService + MediaNoteService } -// NewMediaService creates a concrete MediaService. +// NewMediaService creates a concrete MediaService by wiring role-focused sub-services. func NewMediaService(store repository.MediaServiceStore, clk clock.Clock, mediaRoot string, thumbGen thumb.Generator, prober probe.Prober) MediaService { + helper := &accessHelper{store: store} return &mediaService{ - store: store, - clock: clk, - mediaRoot: mediaRoot, - thumbGen: thumbGen, - prober: prober, - } -} - -// Sentinel errors returned by the media service layer. -var ( - ErrNotFound = errors.New("not found") - ErrForbidden = errors.New("access denied") - ErrShareNotFound = errors.New("share not found") - ErrShareExpired = errors.New("share expired") - ErrMediaNotFound = errors.New("media not found") - ErrUnsupportedExtension = errors.New("unsupported file extension") - ErrAlreadyBootstrapped = errors.New("already bootstrapped") - ErrInvalidCredentials = errors.New("invalid credentials") -) - -// supportedExtensions lists all file extensions accepted by UploadMedia. -var supportedExtensions = map[string]struct{}{ - ".mp4": {}, - ".mkv": {}, - ".avi": {}, - ".mov": {}, - ".wmv": {}, - ".flv": {}, - ".webm": {}, - ".mp3": {}, - ".wav": {}, - ".flac": {}, - ".aac": {}, - ".ogg": {}, - ".m4a": {}, - ".wma": {}, - ".m4b": {}, - ".opus": {}, - ".jpg": {}, - ".jpeg": {}, - ".png": {}, - ".gif": {}, - ".webp": {}, - ".bmp": {}, - ".avif": {}, - ".svg": {}, -} - -func isSupportedExtension(name string) bool { - ext := strings.ToLower(filepath.Ext(name)) - _, ok := supportedExtensions[ext] - return ok -} - -func guessMediaType(name string) model.MediaType { - ext := strings.ToLower(filepath.Ext(name)) - switch ext { - case ".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm": - return model.MediaTypeVideo - case ".mp3", ".wav", ".flac", ".aac", ".ogg", ".m4a", ".wma", ".m4b", ".opus": - return model.MediaTypeAudio - case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif", ".svg": - return model.MediaTypeImage - default: - return model.MediaTypeVideo - } -} - -// generateThumbnail creates a thumbnail for a video or image file. -func (s *mediaService) 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) + MediaBrowseService: NewBrowseService(store, clk, mediaRoot, thumbGen, prober, helper), + MediaWriteService: NewWriteService(store, clk, mediaRoot, thumbGen, prober, helper), + MediaShareService: NewShareService(store, clk, helper), + MediaTagService: NewTagService(store, helper), + MediaFavoriteService: NewFavService(store, helper), + MediaNoteService: NewNoteService(store, clk, helper), } - media.ThumbnailPath = thumbnailPath - return nil } diff --git a/internal/service/media_notes.go b/internal/service/media_notes.go deleted file mode 100644 index 530f19c..0000000 --- a/internal/service/media_notes.go +++ /dev/null @@ -1,29 +0,0 @@ -package service - -import ( - "context" - - "codeberg.org/snonux/player/internal/model" -) - -func (s *mediaService) GetNote(ctx context.Context, mediaID, userID int64) (*model.Note, error) { - if _, err := s.verifyAccess(ctx, mediaID, userID); err != nil { - return nil, err - } - return s.store.GetNote(ctx, mediaID, userID) -} - -func (s *mediaService) UpsertNote(ctx context.Context, note *model.Note) error { - if _, err := s.verifyAccess(ctx, note.MediaID, note.UserID); err != nil { - return err - } - note.UpdatedAt = s.clock.Now() - return s.store.UpsertNote(ctx, note) -} - -func (s *mediaService) DeleteNote(ctx context.Context, mediaID, userID int64) error { - if _, err := s.verifyAccess(ctx, mediaID, userID); err != nil { - return err - } - return s.store.DeleteNote(ctx, mediaID, userID) -} diff --git a/internal/service/note.go b/internal/service/note.go new file mode 100644 index 0000000..da7f923 --- /dev/null +++ b/internal/service/note.go @@ -0,0 +1,47 @@ +package service + +import ( + "context" + + "codeberg.org/snonux/player/internal/clock" + "codeberg.org/snonux/player/internal/model" + "codeberg.org/snonux/player/internal/repository" +) + +// noteService handles CRUD for per-user per-media notes. +type noteService struct { + store repository.NoteServiceStore + clock clock.Clock + helper *accessHelper +} + +// NewNoteService creates a NoteService. +func NewNoteService(store repository.NoteServiceStore, clk clock.Clock, helper *accessHelper) MediaNoteService { + return ¬eService{ + store: store, + clock: clk, + helper: helper, + } +} + +func (s *noteService) GetNote(ctx context.Context, mediaID, userID int64) (*model.Note, error) { + if _, err := s.helper.verifyAccess(ctx, mediaID, userID); err != nil { + return nil, err + } + return s.store.GetNote(ctx, mediaID, userID) +} + +func (s *noteService) UpsertNote(ctx context.Context, note *model.Note) error { + if _, err := s.helper.verifyAccess(ctx, note.MediaID, note.UserID); err != nil { + return err + } + note.UpdatedAt = s.clock.Now() + return s.store.UpsertNote(ctx, note) +} + +func (s *noteService) DeleteNote(ctx context.Context, mediaID, userID int64) error { + if _, err := s.helper.verifyAccess(ctx, mediaID, userID); err != nil { + return err + } + return s.store.DeleteNote(ctx, mediaID, userID) +} diff --git a/internal/service/perm.go b/internal/service/perm.go new file mode 100644 index 0000000..558dac3 --- /dev/null +++ b/internal/service/perm.go @@ -0,0 +1,62 @@ +package service + +import ( + "context" + "fmt" + + "codeberg.org/snonux/player/internal/clock" + "codeberg.org/snonux/player/internal/model" + "codeberg.org/snonux/player/internal/repository" +) + +// permissionAdminService handles set permission management. +type permissionAdminService struct { + store repository.PermissionAdminServiceStore + clock clock.Clock +} + +// NewPermissionAdminService creates a PermissionAdminService. +func NewPermissionAdminService(store repository.PermissionAdminServiceStore, clk clock.Clock) *permissionAdminService { + return &permissionAdminService{store: store, clock: clk} +} + +func (s *permissionAdminService) ListPermissions(ctx context.Context) (*PermissionsMatrix, error) { + sets, err := s.store.ListSets(ctx) + if err != nil { + return nil, fmt.Errorf("list sets: %w", err) + } + + users, err := s.store.ListUsers(ctx) + if err != nil { + return nil, fmt.Errorf("list users: %w", err) + } + + var perms []model.SetPermission + for _, set := range sets { + setPerms, err := s.store.ListPermissionsBySet(ctx, set.ID) + if err != nil { + return nil, fmt.Errorf("list permissions by set: %w", err) + } + perms = append(perms, setPerms...) + } + + return &PermissionsMatrix{ + Sets: sets, + Users: users, + Permissions: perms, + }, nil +} + +func (s *permissionAdminService) GrantPermission(ctx context.Context, setID, userID int64, role model.Role) error { + perm := &model.SetPermission{ + SetID: setID, + UserID: userID, + Role: role, + CreatedAt: s.clock.Now(), + } + return s.store.GrantPermission(ctx, perm) +} + +func (s *permissionAdminService) RevokePermission(ctx context.Context, setID, userID int64) error { + return s.store.RevokePermission(ctx, setID, userID) +} diff --git a/internal/service/scan.go b/internal/service/scan.go new file mode 100644 index 0000000..af17f95 --- /dev/null +++ b/internal/service/scan.go @@ -0,0 +1,75 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + "sync" + "time" + + "codeberg.org/snonux/player/internal/clock" + "codeberg.org/snonux/player/internal/model" + "codeberg.org/snonux/player/internal/scanner" +) + +// scanService handles triggering and tracking media library scans. +type scanService struct { + scanner scanner.Scanner + mediaRoot string + clock clock.Clock + logger *slog.Logger + mu sync.Mutex + scanCancel context.CancelFunc + progress *model.ScanProgress +} + +// NewScanService creates a ScanService. +func NewScanService(sc scanner.Scanner, mediaRoot string, clk clock.Clock, logger *slog.Logger) *scanService { + if logger == nil { |
