summaryrefslogtreecommitdiff
path: root/internal/service
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-03 22:58:04 +0300
committerPaul Buetow <paul@buetow.org>2026-05-03 22:58:04 +0300
commit003aee269bc73d5a0524c603585c567c9987916b (patch)
tree557d81496d1b3dac3bd0f4d533b1fc5f35c83687 /internal/service
parent600f50dac5f33eac14bfbcd15addaf210a260879 (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.go135
-rw-r--r--internal/service/browse.go (renamed from internal/service/media_browse.go)204
-rw-r--r--internal/service/fav.go28
-rw-r--r--internal/service/media.go119
-rw-r--r--internal/service/media_notes.go29
-rw-r--r--internal/service/note.go47
-rw-r--r--internal/service/perm.go62
-rw-r--r--internal/service/scan.go75
-rw-r--r--internal/service/service.go63
-rw-r--r--internal/service/share.go (renamed from internal/service/media_share.go)40
-rw-r--r--internal/service/share_test.go118
-rw-r--r--internal/service/tag.go55
-rw-r--r--internal/service/trash.go22
-rw-r--r--internal/service/user.go52
-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 &noteService{
+ 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 {