From 003aee269bc73d5a0524c603585c567c9987916b Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 3 May 2026 22:58:04 +0300 Subject: 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. --- internal/service/access.go | 83 +++++++ internal/service/admin.go | 135 +++-------- internal/service/browse.go | 459 +++++++++++++++++++++++++++++++++++++ internal/service/fav.go | 28 +++ internal/service/media.go | 119 ++-------- internal/service/media_access.go | 77 ------- internal/service/media_browse.go | 481 --------------------------------------- internal/service/media_notes.go | 29 --- internal/service/media_share.go | 202 ---------------- internal/service/media_write.go | 165 -------------- internal/service/note.go | 47 ++++ internal/service/perm.go | 62 +++++ internal/service/scan.go | 75 ++++++ internal/service/service.go | 63 +++++ internal/service/share.go | 220 ++++++++++++++++++ internal/service/share_test.go | 118 ++++++++++ internal/service/tag.go | 55 +++++ internal/service/trash.go | 22 ++ internal/service/user.go | 52 +++++ internal/service/write.go | 213 +++++++++++++++++ 20 files changed, 1539 insertions(+), 1166 deletions(-) create mode 100644 internal/service/access.go create mode 100644 internal/service/browse.go create mode 100644 internal/service/fav.go delete mode 100644 internal/service/media_access.go delete mode 100644 internal/service/media_browse.go delete mode 100644 internal/service/media_notes.go delete mode 100644 internal/service/media_share.go delete mode 100644 internal/service/media_write.go create mode 100644 internal/service/note.go create mode 100644 internal/service/perm.go create mode 100644 internal/service/scan.go create mode 100644 internal/service/share.go create mode 100644 internal/service/share_test.go create mode 100644 internal/service/tag.go create mode 100644 internal/service/trash.go create mode 100644 internal/service/user.go create mode 100644 internal/service/write.go (limited to 'internal/service') diff --git a/internal/service/access.go b/internal/service/access.go new file mode 100644 index 0000000..891fd98 --- /dev/null +++ b/internal/service/access.go @@ -0,0 +1,83 @@ +package service + +import ( + "context" + "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 (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) + } + if user != nil && user.IsAdmin { + return nil + } + + perm, err := h.store.GetPermission(ctx, setID, userID) + if err != nil { + return fmt.Errorf("get permission: %w", err) + } + if perm != nil && (requiredRole == "" || perm.Role == requiredRole) { + return nil + } + + set, err := h.store.GetSetByID(ctx, setID) + if err != nil { + return fmt.Errorf("get set: %w", err) + } + if set != nil { + for _, p := range set.Permissions { + if p.UserID == userID && (requiredRole == "" || p.Role == requiredRole) { + return nil + } + } + } + + return ErrForbidden +} + +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) + } + if media == nil || media.DeletedAt != nil { + return nil, ErrNotFound + } + + if err := h.checkSetPermission(ctx, media.SetID, userID, ""); err != nil { + return nil, err + } + + return media, nil +} + +// verifyModifyAccess checks that the user has access to the media and is an owner or admin. +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 := h.checkSetPermission(ctx, media.SetID, userID, model.RoleOwner); err != nil { + return nil, err + } + + return media, nil +} + +// verifySetModifyAccess checks that the user is an owner or admin for a set. +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/browse.go b/internal/service/browse.go new file mode 100644 index 0000000..9e2f42c --- /dev/null +++ b/internal/service/browse.go @@ -0,0 +1,459 @@ +package service + +import ( + "context" + "errors" + "fmt" + mrand "math/rand" + "os" + "path/filepath" + "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" +) + +// 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) + } + + sets, err := s.store.ListSets(ctx) + if err != nil { + return nil, fmt.Errorf("list sets: %w", err) + } + + if user != nil && user.IsAdmin { + return sets, nil + } + + perms, err := s.store.ListPermissionsByUser(ctx, userID) + if err != nil { + return nil, fmt.Errorf("list permissions: %w", err) + } + + allowed := make(map[int64]struct{}, len(perms)) + for _, p := range perms { + allowed[p.SetID] = struct{}{} + } + + var filtered []model.Set + for _, set := range sets { + if _, ok := allowed[set.ID]; ok { + filtered = append(filtered, set) + continue + } + for _, p := range set.Permissions { + if p.UserID == userID { + filtered = append(filtered, set) + break + } + } + } + + return filtered, nil +} + +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 + } + + tags, err := s.store.ListTagsByMedia(ctx, mediaID) + if err != nil { + return nil, fmt.Errorf("list tags: %w", err) + } + + fav, err := s.store.IsFavorite(ctx, userID, mediaID) + if err != nil { + return nil, fmt.Errorf("check favorite: %w", err) + } + + note, err := s.store.GetNote(ctx, mediaID, userID) + if err != nil { + return nil, fmt.Errorf("get note: %w", err) + } + + progress, err := s.store.GetProgress(ctx, userID, mediaID) + if err != nil { + return nil, fmt.Errorf("get progress: %w", err) + } + + return &MediaDetail{ + Media: media, + Tags: tags, + Favorite: fav, + Note: note, + Progress: progress, + }, nil +} + +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) + } + + if user != nil && user.IsAdmin { + filter.UserID = userID + return s.store.ListMedia(ctx, filter) + } + + perms, err := s.store.ListPermissionsByUser(ctx, userID) + if err != nil { + return nil, fmt.Errorf("list permissions: %w", err) + } + + allowed := make([]int64, 0, len(perms)) + for _, p := range perms { + allowed = append(allowed, p.SetID) + } + filter.AllowedSetIDs = allowed + filter.UserID = userID + return s.store.ListMedia(ctx, filter) +} + +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 + } + return &FileResult{ + Path: media.AbsPath, + FileName: media.FileName, + FileSize: media.FileSizeBytes, + }, nil +} + +func (s *browseService) DownloadMedia(ctx context.Context, mediaID, userID int64) (*FileResult, error) { + return s.StreamMedia(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 + } + if media.ThumbnailPath == "" { + return nil, errors.New("thumbnail not found") + } + info, err := os.Stat(media.ThumbnailPath) + if err != nil { + return nil, fmt.Errorf("stat thumbnail: %w", err) + } + return &FileResult{ + Path: media.ThumbnailPath, + FileName: filepath.Base(media.ThumbnailPath), + FileSize: info.Size(), + }, nil +} + +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 +} + +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 + } + + parent = filepath.ToSlash(strings.Trim(parent, "/")) + media, err := s.store.ListMedia(ctx, repository.MediaFilter{SetID: &setID}) + if err != nil { + return nil, fmt.Errorf("list media: %w", 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 + } + + type folderContent struct { + files []model.Media + subfolders map[string]struct{} + } + folderMap := make(map[string]*folderContent) + var items []model.Media + + for _, m := range media { + if m.DeletedAt != nil { + continue + } + rel := filepath.ToSlash(m.RelPath) + prefix := "" + if parent != "" { + prefix = parent + "/" + } + if !strings.HasPrefix(rel, prefix) { + continue + } + suffix := strings.TrimPrefix(rel, prefix) + if suffix == "" { + continue + } + parts := strings.SplitN(suffix, "/", 2) + name := parts[0] + if len(parts) == 1 { + items = append(items, m) + continue + } + fc, ok := folderMap[name] + if !ok { + fc = &folderContent{subfolders: make(map[string]struct{})} + folderMap[name] = fc + } + rest := parts[1] + subparts := strings.SplitN(rest, "/", 2) + if len(subparts) == 1 { + fc.files = append(fc.files, m) + } else { + fc.subfolders[subparts[0]] = struct{}{} + } + } + + var folders []BrowseFolder + for name, fc := range folderMap { + total := len(fc.files) + len(fc.subfolders) + if total == 1 && len(fc.files) == 1 { + items = append(items, fc.files[0]) + } else { + subPath := filepath.Join(parent, name) + folderDir := filepath.Clean(filepath.Join(s.mediaRoot, set.RootPath, filepath.FromSlash(subPath))) + coverPath := filepath.Join(folderDir, ".cover.jpg") + _, err := os.Stat(coverPath) + _, hasDirectCover := folderCoverFile(folderDir) + hasCover := err == nil || hasDirectCover || randomFolderThumbnail(media, filepath.ToSlash(subPath)) != "" + folders = append(folders, BrowseFolder{Name: name, HasCover: hasCover}) + } + } + sort.Slice(folders, func(i, j int) bool { return folders[i].Name < folders[j].Name }) + + return &BrowseResult{ + CurrentPath: parent, + Folders: folders, + Media: items, + }, 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 != "" { + prefix += "/" + } + var candidates []string + for _, m := range media { + if m.DeletedAt != nil || m.ThumbnailPath == "" { + continue + } + rel := filepath.ToSlash(m.RelPath) + if prefix != "" && !strings.HasPrefix(rel, prefix) { + continue + } + if prefix == "" && strings.Contains(rel, "/") { + continue + } + candidates = append(candidates, m.ThumbnailPath) + } + if len(candidates) == 0 { + return "" + } + return candidates[mrand.Intn(len(candidates))] +} + +func folderCoverFile(dir string) (*FileResult, bool) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, false + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + switch strings.ToLower(entry.Name()) { + case "cover.jpg", "cover.jpeg", "cover.png", "cover.gif", "folder.jpg", "folder.jpeg", "folder.png", "folder.gif": + info, err := entry.Info() + if err != nil { + return nil, false + } + path := filepath.Join(dir, entry.Name()) + return &FileResult{Path: path, FileName: entry.Name(), FileSize: info.Size()}, true + } + } + return nil, false +} 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_access.go b/internal/service/media_access.go deleted file mode 100644 index f0a9ddb..0000000 --- a/internal/service/media_access.go +++ /dev/null @@ -1,77 +0,0 @@ -package service - -import ( - "context" - "fmt" - - "codeberg.org/snonux/player/internal/model" -) - -// 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) - if err != nil { - return fmt.Errorf("get user: %w", err) - } - if user != nil && user.IsAdmin { - return nil - } - - perm, err := s.store.GetPermission(ctx, setID, userID) - if err != nil { - return fmt.Errorf("get permission: %w", err) - } - if perm != nil && (requiredRole == "" || perm.Role == requiredRole) { - return nil - } - - set, err := s.store.GetSetByID(ctx, setID) - if err != nil { - return fmt.Errorf("get set: %w", err) - } - if set != nil { - for _, p := range set.Permissions { - if p.UserID == userID && (requiredRole == "" || p.Role == requiredRole) { - return nil - } - } - } - - return ErrForbidden -} - -func (s *mediaService) verifyAccess(ctx context.Context, mediaID, userID int64) (*model.Media, error) { - media, err := s.store.GetMediaByID(ctx, mediaID) - if err != nil { - return nil, fmt.Errorf("get media: %w", err) - } - if media == nil || media.DeletedAt != nil { - return nil, ErrNotFound - } - - if err := s.checkSetPermission(ctx, media.SetID, userID, ""); err != nil { - return nil, err - } - - return media, nil -} - -// 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) - if err != nil { - return nil, err - } - - if err := s.checkSetPermission(ctx, media.SetID, userID, model.RoleOwner); err != nil { - return nil, err - } - - return media, nil -} - -// 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) -} diff --git a/internal/service/media_browse.go b/internal/service/media_browse.go deleted file mode 100644 index 6635688..0000000 --- a/internal/service/media_browse.go +++ /dev/null @@ -1,481 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - mrand "math/rand" - "os" - "path/filepath" - "sort" - "strings" - - "codeberg.org/snonux/player/internal/model" - "codeberg.org/snonux/player/internal/repository" -) - -func (s *mediaService) 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) - } - - sets, err := s.store.ListSets(ctx) - if err != nil { - return nil, fmt.Errorf("list sets: %w", err) - } - - if user != nil && user.IsAdmin { - return sets, nil - } - - perms, err := s.store.ListPermissionsByUser(ctx, userID) - if err != nil { - return nil, fmt.Errorf("list permissions: %w", err) - } - - allowed := make(map[int64]struct{}, len(perms)) - for _, p := range perms { - allowed[p.SetID] = struct{}{} - } - - var filtered []model.Set - for _, set := range sets { - if _, ok := allowed[set.ID]; ok { - filtered = append(filtered, set) - continue - } - for _, p := range set.Permissions { - if p.UserID == userID { - filtered = append(filtered, set) - break - } - } - } - - return filtered, nil -} - -func (s *mediaService) GetMediaDetail(ctx context.Context, mediaID, userID int64) (*MediaDetail, error) { - media, err := s.verifyAccess(ctx, mediaID, userID) - if err != nil { - return nil, err - } - - tags, err := s.store.ListTagsByMedia(ctx, mediaID) - if err != nil { - return nil, fmt.Errorf("list tags: %w", err) - } - - fav, err := s.store.IsFavorite(ctx, userID, mediaID) - if err != nil { - return nil, fmt.Errorf("check favorite: %w", err) - } - - note, err := s.store.GetNote(ctx, mediaID, userID) - if err != nil { - return nil, fmt.Errorf("get note: %w", err) - } - - progress, err := s.store.GetProgress(ctx, userID, mediaID) - if err != nil { - return nil, fmt.Errorf("get progress: %w", err) - } - - return &MediaDetail{ - Media: media, - Tags: tags, - Favorite: fav, - Note: note, - Progress: progress, - }, nil -} - -func (s *mediaService) 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) - } - - if user != nil && user.IsAdmin { - filter.UserID = userID - return s.store.ListMedia(ctx, filter) - } - - perms, err := s.store.ListPermissionsByUser(ctx, userID) - if err != nil { - return nil, fmt.Errorf("list permissions: %w", err) - } - - allowed := make([]int64, 0, len(perms)) - for _, p := range perms { - allowed = append(allowed, p.SetID) - } - filter.AllowedSetIDs = allowed - filter.UserID = userID - 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) - if err != nil { - return nil, err - } - return &FileResult{ - Path: media.AbsPath, - FileName: media.FileName, - FileSize: media.FileSizeBytes, - }, nil -} - -func (s *mediaService) 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) - if err != nil { - return nil, err - } - if media.ThumbnailPath == "" { - return nil, errors.New("thumbnail not found") - } - info, err := os.Stat(media.ThumbnailPath) - if err != nil { - return nil, fmt.Errorf("stat thumbnail: %w", err) - } - return &FileResult{ - Path: media.ThumbnailPath, - FileName: filepath.Base(media.ThumbnailPath), - FileSize: info.Size(), - }, nil -} - -func (s *mediaService) RegenerateThumbnail(ctx context.Context, mediaID, userID int64) error { - media, err := s.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 *mediaService) RegenerateSetCover(ctx context.Context, setID int64, folder string, userID int64) error { - if err := s.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 *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 { - return nil, err - } - - parent = filepath.ToSlash(strings.Trim(parent, "/")) - media, err := s.store.ListMedia(ctx, repository.MediaFilter{SetID: &setID}) - if err != nil { - return nil, fmt.Errorf("list media: %w", 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 - } - - type folderContent struct { - files []model.Media - subfolders map[string]struct{} - } - folderMap := make(map[string]*folderContent) - var items []model.Media - - for _, m := range media { - if m.DeletedAt != nil { - continue - } - rel := filepath.ToSlash(m.RelPath) - prefix := "" - if parent != "" { - prefix = parent + "/" - } - if !strings.HasPrefix(rel, prefix) { - continue - } - suffix := strings.TrimPrefix(rel, prefix) - if suffix == "" { - continue - } - 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{})} - folderMap[name] = fc - } - rest := parts[1] - subparts := strings.SplitN(rest, "/", 2) - if len(subparts) == 1 { - fc.files = append(fc.files, m) - } else { - fc.subfolders[subparts[0]] = struct{}{} - } - } - - var folders []BrowseFolder - 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) - folderDir := filepath.Clean(filepath.Join(s.mediaRoot, set.RootPath, filepath.FromSlash(subPath))) - coverPath := filepath.Join(folderDir, ".cover.jpg") - _, err := os.Stat(coverPath) - _, hasDirectCover := folderCoverFile(folderDir) - hasCover := err == nil || hasDirectCover || randomFolderThumbnail(media, filepath.ToSlash(subPath)) != "" - folders = append(folders, BrowseFolder{Name: name, HasCover: hasCover}) - } - } - sort.Slice(folders, func(i, j int) bool { return folders[i].Name < folders[j].Name }) - - return &BrowseResult{ - CurrentPath: parent, - Folders: folders, - Media: items, - }, nil -} - -func randomFolderThumbnail(media []model.Media, folder string) string { - prefix := filepath.ToSlash(strings.Trim(folder, "/")) - if prefix != "" { - prefix += "/" - } - var candidates []string - for _, m := range media { - if m.DeletedAt != nil || m.ThumbnailPath == "" { - continue - } - rel := filepath.ToSlash(m.RelPath) - if prefix != "" && !strings.HasPrefix(rel, prefix) { - continue - } - if prefix == "" && strings.Contains(rel, "/") { - continue - } - candidates = append(candidates, m.ThumbnailPath) - } - if len(candidates) == 0 { - return "" - } - return candidates[mrand.Intn(len(candidates))] -} - -func folderCoverFile(dir string) (*FileResult, bool) { - entries, err := os.ReadDir(dir) - if err != nil { - return nil, false - } - for _, entry := range entries { - if entry.IsDir() { - continue - } - switch strings.ToLower(entry.Name()) { - case "cover.jpg", "cover.jpeg", "cover.png", "cover.gif", "folder.jpg", "folder.jpeg", "folder.png", "folder.gif": - info, err := entry.Info() - if err != nil { - return nil, false - } - path := filepath.Join(dir, entry.Name()) - return &FileResult{Path: path, FileName: entry.Name(), FileSize: info.Size()}, true - } - } - return nil, false -} 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/media_share.go b/internal/service/media_share.go deleted file mode 100644 index c00530f..0000000 --- a/internal/service/media_share.go +++ /dev/null @@ -1,202 +0,0 @@ -package service - -import ( - "context" - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "path/filepath" - "time" - - "codeberg.org/snonux/player/internal/model" -) - -func generateToken() (string, error) { - b := make([]byte, 16) - if _, err := rand.Read(b); err != nil { - return "", err - } - return hex.EncodeToString(b), nil -} - -func (s *mediaService) CreateShare(ctx context.Context, userID, mediaID int64, expiresAt time.Time) (*model.Share, error) { - _, err := s.verifyAccess(ctx, mediaID, userID) - if err != nil { - return nil, err - } - - token, err := generateToken() - if err != nil { - return nil, fmt.Errorf("generate token: %w", err) - } - - share := &model.Share{ - Token: token, - MediaID: mediaID, - CreatedBy: userID, - CreatedAt: s.clock.Now(), - ExpiresAt: expiresAt, - } - - if err := s.store.CreateShare(ctx, share); err != nil { - return nil, fmt.Errorf("create share: %w", err) - } - return share, nil -} - -func (s *mediaService) ListShares(ctx context.Context, mediaID, userID int64) ([]model.Share, error) { - _, err := s.verifyAccess(ctx, mediaID, userID) - if err != nil { - return nil, err - } - return s.store.ListSharesByMedia(ctx, mediaID) -} - -func (s *mediaService) RevokeShare(ctx context.Context, token string, userID int64) error { - share, err := s.store.GetShareByToken(ctx, token) - if err != nil { - return fmt.Errorf("get share: %w", err) - } - if share == nil { - return errors.New("share not found") - } - - _, err = s.verifyAccess(ctx, share.MediaID, userID) - if err != nil { - return err - } - - return s.store.DeleteShare(ctx, token) -} - -func (s *mediaService) ValidateShareToken(ctx context.Context, token string) (*model.Share, error) { - share, err := s.store.GetShareByToken(ctx, token) - if err != nil { - return nil, fmt.Errorf("get share: %w", err) - } - if share == nil { - return nil, ErrShareNotFound - } - - now := s.clock.Now() - if now.After(share.ExpiresAt) { - return nil, ErrShareExpired - } - - if share.MaxUses != nil && share.UsedCount >= *share.MaxUses { - return nil, ErrShareExpired - } - - return share, nil -} - -func (s *mediaService) StreamSharedMedia(ctx context.Context, token string) (*FileResult, error) { - share, err := s.ValidateShareToken(ctx, token) - if err != nil { - return nil, err - } - - media, err := s.store.GetMediaByID(ctx, share.MediaID) - if err != nil { - return nil, fmt.Errorf("get media: %w", err) - } - if media == nil { - return nil, ErrMediaNotFound - } - - _ = s.store.UseShare(ctx, token) - - return &FileResult{ - Path: media.AbsPath, - FileName: media.FileName, - FileSize: media.FileSizeBytes, - }, nil -} - -func (s *mediaService) GetSharedMedia(ctx context.Context, token string) (*GetSharedMediaResult, error) { - share, err := s.ValidateShareToken(ctx, token) - if err != nil { - return nil, err - } - - media, err := s.store.GetMediaByID(ctx, share.MediaID) - if err != nil { - return nil, fmt.Errorf("get media: %w", err) - } - if media == nil { - return nil, ErrMediaNotFound - } - - return &GetSharedMediaResult{ - Media: &SharedMediaView{ - ID: media.ID, - FileName: media.FileName, - Type: media.Type, - Duration: media.Duration, - Codec: media.Codec, - Resolution: media.Resolution, - Bitrate: media.Bitrate, - FileSizeBytes: media.FileSizeBytes, - }, - HasThumb: media.ThumbnailPath != "", - StreamURL: fmt.Sprintf("/s/%s/stream", token), - DownloadURL: fmt.Sprintf("/s/%s/download", token), - ThumbURL: fmt.Sprintf("/s/%s/thumbnail", token), - }, nil -} - -func (s *mediaService) GetSharedThumbnail(ctx context.Context, token string) (*FileResult, error) { - share, err := s.ValidateShareToken(ctx, token) - if err != nil { - return nil, err - } - - media, err := s.store.GetMediaByID(ctx, share.MediaID) - if err != nil { - return nil, fmt.Errorf("get media: %w", err) - } - if media == nil { - return nil, ErrMediaNotFound - } - if media.ThumbnailPath == "" { - return nil, ErrMediaNotFound - } - - return &FileResult{ - Path: media.ThumbnailPath, - FileName: filepath.Base(media.ThumbnailPath), - }, nil -} - -func (s *mediaService) ListMyShares(ctx context.Context, userID int64) ([]ShareInfo, error) { - shares, err := s.store.ListSharesByUser(ctx, userID) - if err != nil { - return nil, fmt.Errorf("list shares: %w", err) - } - - result := make([]ShareInfo, 0, len(shares)) - for _, sh := range shares { - media, err := s.store.GetMediaByID(ctx, sh.MediaID) - if err != nil { - return nil, fmt.Errorf("get media: %w", err) - } - fileName := "" - mediaType := model.MediaTypeVideo - if media != nil { - fileName = media.FileName - mediaType = media.Type - } - result = append(result, ShareInfo{ - Token: sh.Token, - MediaID: sh.MediaID, - FileName: fileName, - MediaType: mediaType, - CreatedAt: sh.CreatedAt, - ExpiresAt: sh.ExpiresAt, - MaxUses: sh.MaxUses, - UsedCount: sh.UsedCount, - }) - } - return result, nil -} diff --git a/internal/service/media_write.go b/internal/service/media_write.go deleted file mode 100644 index 4343db8..0000000 --- a/internal/service/media_write.go +++ /dev/null @@ -1,165 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "strings" - - "codeberg.org/snonux/player/internal/model" -) - -func (s *mediaService) SoftDeleteMedia(ctx context.Context, mediaID, userID int64) error { - _, err := s.verifyModifyAccess(ctx, mediaID, userID) - if err != nil { - return err - } - return s.store.SoftDeleteMedia(ctx, mediaID) -} - -func (s *mediaService) RestoreMedia(ctx context.Context, mediaID, userID int64) error { - _, err := s.verifyModifyAccess(ctx, mediaID, userID) - if err != nil { - return err - } - return s.store.RestoreMedia(ctx, mediaID) -} - -func (s *mediaService) 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 *mediaService) 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.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 -} - -// saveUploadedMedia writes data to disk and creates a minimal media row. -func (s *mediaService) 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 -} - -// probeMedia extracts metadata from the uploaded file. -func (s *mediaService) 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 -} 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 { + logger = slog.Default() + } + return &scanService{ + scanner: sc, + mediaRoot: mediaRoot, + clock: clk, + logger: logger, + } +} + +func (s *scanService) 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() + + 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 +} + +func (s *scanService) ScanProgress(ctx context.Context) model.ScanProgress { + s.mu.Lock() + progress := s.progress + s.mu.Unlock() + if progress == nil { + return model.ScanProgress{} + } + return progress.Copy() +} diff --git a/internal/service/service.go b/internal/service/service.go index 95125ba..4519441 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -3,13 +3,76 @@ package service import ( "context" + "errors" "io" + "path/filepath" + "strings" "time" "codeberg.org/snonux/player/internal/model" "codeberg.org/snonux/player/internal/repository" ) +// Sentinel errors returned by the 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 + } +} + // MediaBrowseService handles read-only browsing and media streaming operations. type MediaBrowseService interface { // ListSets returns the sets visible to a user. diff --git a/internal/service/share.go b/internal/service/share.go new file mode 100644 index 0000000..c87d8f6 --- /dev/null +++ b/internal/service/share.go @@ -0,0 +1,220 @@ +package service + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "path/filepath" + "time" + + "codeberg.org/snonux/player/internal/clock" + "codeberg.org/snonux/player/internal/model" + "codeberg.org/snonux/player/internal/repository" +) + +// shareService handles creation, validation and revocation of share links. +type shareService struct { + store repository.ShareServiceStore + clock clock.Clock + helper *accessHelper +} + +// NewShareService creates a ShareService. +func NewShareService(store repository.ShareServiceStore, clk clock.Clock, helper *accessHelper) MediaShareService { + return &shareService{ + store: store, + clock: clk, + helper: helper, + } +} + +func generateToken() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func (s *shareService) CreateShare(ctx context.Context, userID, mediaID int64, expiresAt time.Time) (*model.Share, error) { + _, err := s.helper.verifyAccess(ctx, mediaID, userID) + if err != nil { + return nil, err + } + + token, err := generateToken() + if err != nil { + return nil, fmt.Errorf("generate token: %w", err) + } + + share := &model.Share{ + Token: token, + MediaID: mediaID, + CreatedBy: userID, + CreatedAt: s.clock.Now(), + ExpiresAt: expiresAt, + } + + if err := s.store.CreateShare(ctx, share); err != nil { + return nil, fmt.Err