diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-01 20:14:19 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-01 20:14:19 +0300 |
| commit | 74c2e0982f336ab135d8b63d26fa49bcd58e57a9 (patch) | |
| tree | 689b764fadd7d6ca35598c131d0552b3d9374e3d | |
| parent | 4e35889c33491be33f8d557affb1c9d822372e7a (diff) | |
service: split media.go (745 lines) into 5 focused files
Split the monolithic media.go into cohesive files to fix KISS/SRP violation:
- media_access.go: permission verification helpers (verifyAccess, verifyModifyAccess, verifySetModifyAccess)
- media_browse.go: browsing, listing, streaming, thumbnails, set cover, tags and favorites
- media_write.go: upload, create, soft-delete, restore
- media_share.go: share token creation, validation, revocation, streaming via token
- media_notes.go: note get/upsert/delete
The shared mediaService struct, NewMediaService constructor, and common helpers
(supported extensions, type guessing, thumbnail generation) remain in media.go.
No functional changes — all tests pass.
| -rw-r--r-- | internal/service/media.go | 685 | ||||
| -rw-r--r-- | internal/service/media_access.go | 121 | ||||
| -rw-r--r-- | internal/service/media_browse.go | 272 | ||||
| -rw-r--r-- | internal/service/media_notes.go | 29 | ||||
| -rw-r--r-- | internal/service/media_share.go | 114 | ||||
| -rw-r--r-- | internal/service/media_write.go | 156 |
6 files changed, 713 insertions, 664 deletions
diff --git a/internal/service/media.go b/internal/service/media.go index 064178a..58bb524 100644 --- a/internal/service/media.go +++ b/internal/service/media.go @@ -2,16 +2,11 @@ package service import ( "context" - "crypto/rand" - "encoding/hex" "errors" "fmt" - "io" - mrand "math/rand" "os" "path/filepath" "strings" - "time" "codeberg.org/snonux/player/internal/clock" "codeberg.org/snonux/player/internal/model" @@ -21,13 +16,13 @@ import ( ) var ( - _ MediaBrowseService = (*mediaService)(nil) - _ MediaWriteService = (*mediaService)(nil) - _ MediaShareService = (*mediaService)(nil) - _ MediaTagService = (*mediaService)(nil) - _ MediaFavoriteService = (*mediaService)(nil) - _ MediaNoteService = (*mediaService)(nil) - _ MediaService = (*mediaService)(nil) + _ MediaBrowseService = (*mediaService)(nil) + _ MediaWriteService = (*mediaService)(nil) + _ MediaShareService = (*mediaService)(nil) + _ MediaTagService = (*mediaService)(nil) + _ MediaFavoriteService = (*mediaService)(nil) + _ MediaNoteService = (*mediaService)(nil) + _ MediaService = (*mediaService)(nil) ) // mediaService is the concrete implementation of MediaService. @@ -52,11 +47,11 @@ func NewMediaService(store repository.MediaServiceStore, clk clock.Clock, mediaR // 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") + 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") ) @@ -84,518 +79,16 @@ func isSupportedExtension(name string) bool { return ok } -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) 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 - } - - user, err := s.store.GetUserByID(ctx, userID) - if err != nil { - return nil, fmt.Errorf("get user: %w", err) - } - if user != nil && user.IsAdmin { - return media, nil - } - - set, err := s.store.GetSetByID(ctx, media.SetID) - if err != nil { - return nil, fmt.Errorf("get set: %w", err) - } - if set == nil { - return nil, ErrNotFound - } - - for _, p := range set.Permissions { - if p.UserID == userID { - return media, nil - } - } - - perm, err := s.store.GetPermission(ctx, media.SetID, userID) - if err != nil { - return nil, fmt.Errorf("get permission: %w", err) - } - if perm != nil { - return media, nil - } - - return nil, ErrForbidden -} - -// 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 - } - - user, err := s.store.GetUserByID(ctx, userID) - if err != nil { - return nil, fmt.Errorf("get user: %w", err) - } - if user != nil && user.IsAdmin { - return media, nil - } - - perm, err := s.store.GetPermission(ctx, media.SetID, userID) - if err != nil { - return nil, fmt.Errorf("get permission: %w", err) - } - if perm != nil && perm.Role == model.RoleOwner { - return media, nil - } - - set, err := s.store.GetSetByID(ctx, media.SetID) - if err != nil { - return nil, fmt.Errorf("get set: %w", err) - } - if set != nil { - for _, p := range set.Permissions { - if p.UserID == userID && p.Role == model.RoleOwner { - return media, nil - } - } - } - - return nil, ErrForbidden -} - -// verifySetModifyAccess checks that the user is an owner or admin for a set. -func (s *mediaService) verifySetModifyAccess(ctx context.Context, setID, userID int64) 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 && perm.Role == model.RoleOwner { - 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 && p.Role == model.RoleOwner { - return nil - } - } - } - - return ErrForbidden -} - -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 { - return errors.New("thumbnails can only be generated for video 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, 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) - } - - var candidates []model.Media - for _, m := range media { - if m.Type == model.MediaTypeVideo && m.DeletedAt == nil { - 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))] - } - - coverPath := filepath.Join(filepath.Clean(filepath.Join(s.mediaRoot, set.RootPath)), ".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) - } - - set.CoverThumbnailPath = coverPath - if err := s.store.UpdateSet(ctx, set); err != nil { - return fmt.Errorf("update set: %w", err) - } - return 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) -} - -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 - - if media.Type == model.MediaTypeVideo { - 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) +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": + return model.MediaTypeAudio + default: + return model.MediaTypeVideo } - return meta, nil } // generateThumbnail creates a thumbnail for a video file. @@ -617,139 +110,3 @@ func (s *mediaService) generateThumbnail(ctx context.Context, media *model.Media media.ThumbnailPath = thumbnailPath return nil } - -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": - return model.MediaTypeAudio - default: - return model.MediaTypeVideo - } -} - -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) 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_access.go b/internal/service/media_access.go new file mode 100644 index 0000000..7320c94 --- /dev/null +++ b/internal/service/media_access.go @@ -0,0 +1,121 @@ +package service + +import ( + "context" + "fmt" + + "codeberg.org/snonux/player/internal/model" +) + +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 + } + + user, err := s.store.GetUserByID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + if user != nil && user.IsAdmin { + return media, nil + } + + set, err := s.store.GetSetByID(ctx, media.SetID) + if err != nil { + return nil, fmt.Errorf("get set: %w", err) + } + if set == nil { + return nil, ErrNotFound + } + + for _, p := range set.Permissions { + if p.UserID == userID { + return media, nil + } + } + + perm, err := s.store.GetPermission(ctx, media.SetID, userID) + if err != nil { + return nil, fmt.Errorf("get permission: %w", err) + } + if perm != nil { + return media, nil + } + + return nil, ErrForbidden +} + +// 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 + } + + user, err := s.store.GetUserByID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + if user != nil && user.IsAdmin { + return media, nil + } + + perm, err := s.store.GetPermission(ctx, media.SetID, userID) + if err != nil { + return nil, fmt.Errorf("get permission: %w", err) + } + if perm != nil && perm.Role == model.RoleOwner { + return media, nil + } + + set, err := s.store.GetSetByID(ctx, media.SetID) + if err != nil { + return nil, fmt.Errorf("get set: %w", err) + } + if set != nil { + for _, p := range set.Permissions { + if p.UserID == userID && p.Role == model.RoleOwner { + return media, nil + } + } + } + + return nil, ErrForbidden +} + +// verifySetModifyAccess checks that the user is an owner or admin for a set. +func (s *mediaService) verifySetModifyAccess(ctx context.Context, setID, userID int64) 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 && perm.Role == model.RoleOwner { + 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 && p.Role == model.RoleOwner { + return nil + } + } + } + + return ErrForbidden +} diff --git a/internal/service/media_browse.go b/internal/service/media_browse.go new file mode 100644 index 0000000..2a60516 --- /dev/null +++ b/internal/service/media_browse.go @@ -0,0 +1,272 @@ +package service + +import ( + "context" + "errors" + "fmt" + mrand "math/rand" + "os" + "path/filepath" + "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 { + return errors.New("thumbnails can only be generated for video 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, 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) + } + + var candidates []model.Media + for _, m := range media { + if m.Type == model.MediaTypeVideo && m.DeletedAt == nil { + 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))] + } + + coverPath := filepath.Join(filepath.Clean(filepath.Join(s.mediaRoot, set.RootPath)), ".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) + } + + set.CoverThumbnailPath = coverPath + if err := s.store.UpdateSet(ctx, set); err != nil { + return fmt.Errorf("update set: %w", err) + } + return 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) +} diff --git a/internal/service/media_notes.go b/internal/service/media_notes.go new file mode 100644 index 0000000..530f19c --- /dev/null +++ b/internal/service/media_notes.go @@ -0,0 +1,29 @@ +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 { |
