合并 origin/main 设备注册后,纳入本地 music 模块、seed 命令与 popadmin 相关路由/文档;music migration 编号为 0020 以避免与 0019_devices 冲突。 Co-authored-by: Cursor <cursoragent@cursor.com>
335 lines
8.4 KiB
Go
335 lines
8.4 KiB
Go
package logic
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"path"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"spark-server/internal/module/music/entity"
|
||
"spark-server/internal/pkg/errs"
|
||
)
|
||
|
||
var ErrNotReady = errors.New("music: service not ready")
|
||
|
||
type Repository interface {
|
||
ListAll(ctx context.Context) ([]entity.Track, error)
|
||
ListEnabled(ctx context.Context) ([]entity.Track, error)
|
||
Get(ctx context.Context, id int64) (*entity.Track, error)
|
||
Insert(ctx context.Context, t *entity.Track) error
|
||
Update(ctx context.Context, t *entity.Track) error
|
||
Delete(ctx context.Context, id int64) error
|
||
MemberPreviewSeconds(ctx context.Context) (int, error)
|
||
SetSetting(ctx context.Context, key, value string) error
|
||
}
|
||
|
||
type Objects interface {
|
||
Ready() bool
|
||
PutLarge(ctx context.Context, key string, body []byte, contentType string, maxBytes int) error
|
||
Delete(ctx context.Context, key string) error
|
||
}
|
||
|
||
type Service struct {
|
||
repo Repository
|
||
objects Objects
|
||
publicURL func(objectKey string) string
|
||
}
|
||
|
||
func NewService(repo Repository, objects Objects, publicBase, bucket string) *Service {
|
||
base := strings.TrimRight(strings.TrimSpace(publicBase), "/")
|
||
bkt := strings.TrimSpace(bucket)
|
||
if bkt == "" {
|
||
bkt = "spark"
|
||
}
|
||
return &Service{
|
||
repo: repo,
|
||
objects: objects,
|
||
publicURL: func(key string) string {
|
||
key = strings.TrimPrefix(strings.TrimSpace(key), "/")
|
||
if key == "" || base == "" {
|
||
return ""
|
||
}
|
||
return base + "/" + bkt + "/" + key
|
||
},
|
||
}
|
||
}
|
||
|
||
func (s *Service) Ready() bool {
|
||
return s != nil && s.repo != nil
|
||
}
|
||
|
||
type UpsertInput struct {
|
||
Title string `json:"title"`
|
||
RequireMember *bool `json:"requireMember"`
|
||
SortOrder *int `json:"sortOrder"`
|
||
Enabled *bool `json:"enabled"`
|
||
}
|
||
|
||
func (s *Service) ListAdmin(ctx context.Context) ([]entity.Track, error) {
|
||
if !s.Ready() {
|
||
return nil, ErrNotReady
|
||
}
|
||
return s.repo.ListAll(ctx)
|
||
}
|
||
|
||
func (s *Service) Playlist(ctx context.Context) (*entity.Playlist, error) {
|
||
if !s.Ready() {
|
||
return nil, ErrNotReady
|
||
}
|
||
items, err := s.repo.ListEnabled(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
sec, err := s.repo.MemberPreviewSeconds(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &entity.Playlist{Items: items, MemberPreviewSeconds: sec}, nil
|
||
}
|
||
|
||
func (s *Service) Settings(ctx context.Context) (*entity.Settings, error) {
|
||
if !s.Ready() {
|
||
return nil, ErrNotReady
|
||
}
|
||
sec, err := s.repo.MemberPreviewSeconds(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &entity.Settings{MemberPreviewSeconds: sec}, nil
|
||
}
|
||
|
||
func (s *Service) UpdateSettings(ctx context.Context, previewSeconds int) (*entity.Settings, error) {
|
||
if !s.Ready() {
|
||
return nil, ErrNotReady
|
||
}
|
||
if previewSeconds < 0 || previewSeconds > 3600 {
|
||
return nil, errs.ErrInvalidArgument.WithMsg("memberPreviewSeconds 须在 0~3600")
|
||
}
|
||
if err := s.repo.SetSetting(ctx, entity.SettingMemberPreviewSeconds, strconv.Itoa(previewSeconds)); err != nil {
|
||
return nil, err
|
||
}
|
||
return &entity.Settings{MemberPreviewSeconds: previewSeconds}, nil
|
||
}
|
||
|
||
func (s *Service) Create(ctx context.Context, in UpsertInput) (*entity.Track, error) {
|
||
if !s.Ready() {
|
||
return nil, ErrNotReady
|
||
}
|
||
title := strings.TrimSpace(in.Title)
|
||
if title == "" {
|
||
return nil, errs.ErrInvalidArgument.WithMsg("title 不能为空")
|
||
}
|
||
t := &entity.Track{
|
||
Title: title,
|
||
RequireMember: false,
|
||
SortOrder: 0,
|
||
Enabled: true,
|
||
}
|
||
if in.RequireMember != nil {
|
||
t.RequireMember = *in.RequireMember
|
||
}
|
||
if in.SortOrder != nil {
|
||
t.SortOrder = *in.SortOrder
|
||
}
|
||
if in.Enabled != nil {
|
||
t.Enabled = *in.Enabled
|
||
}
|
||
if err := s.repo.Insert(ctx, t); err != nil {
|
||
return nil, err
|
||
}
|
||
return t, nil
|
||
}
|
||
|
||
func (s *Service) Update(ctx context.Context, id int64, in UpsertInput) (*entity.Track, error) {
|
||
if !s.Ready() {
|
||
return nil, ErrNotReady
|
||
}
|
||
t, err := s.repo.Get(ctx, id)
|
||
if err != nil {
|
||
return nil, errs.ErrNotFound.WithMsg("曲目不存在")
|
||
}
|
||
if in.Title != "" {
|
||
t.Title = strings.TrimSpace(in.Title)
|
||
}
|
||
if t.Title == "" {
|
||
return nil, errs.ErrInvalidArgument.WithMsg("title 不能为空")
|
||
}
|
||
if in.RequireMember != nil {
|
||
t.RequireMember = *in.RequireMember
|
||
}
|
||
if in.SortOrder != nil {
|
||
t.SortOrder = *in.SortOrder
|
||
}
|
||
if in.Enabled != nil {
|
||
t.Enabled = *in.Enabled
|
||
}
|
||
if err := s.repo.Update(ctx, t); err != nil {
|
||
return nil, err
|
||
}
|
||
return t, nil
|
||
}
|
||
|
||
func (s *Service) Delete(ctx context.Context, id int64) error {
|
||
if !s.Ready() {
|
||
return ErrNotReady
|
||
}
|
||
t, err := s.repo.Get(ctx, id)
|
||
if err != nil {
|
||
return errs.ErrNotFound.WithMsg("曲目不存在")
|
||
}
|
||
_ = s.deleteObject(ctx, t.CoverKey)
|
||
_ = s.deleteObject(ctx, t.AudioKey)
|
||
return s.repo.Delete(ctx, id)
|
||
}
|
||
|
||
func (s *Service) UploadCover(ctx context.Context, id int64, filename string, body []byte, contentType string) (*entity.Track, error) {
|
||
if !s.Ready() {
|
||
return nil, ErrNotReady
|
||
}
|
||
if len(body) == 0 {
|
||
return nil, errs.ErrInvalidArgument.WithMsg("请选择封面文件")
|
||
}
|
||
if len(body) > entity.MaxCoverBytes {
|
||
return nil, errs.ErrInvalidArgument.WithMsg("封面不能超过 5MB")
|
||
}
|
||
ct := normalizeMIME(contentType, filename)
|
||
if !isImageMIME(ct) {
|
||
return nil, errs.ErrInvalidArgument.WithMsg("封面仅支持 jpg/png/webp/gif")
|
||
}
|
||
t, err := s.repo.Get(ctx, id)
|
||
if err != nil {
|
||
return nil, errs.ErrNotFound.WithMsg("曲目不存在")
|
||
}
|
||
ext := extFor(ct, filename, "jpg")
|
||
key := fmt.Sprintf("perm/music/cover/%d/%d.%s", id, time.Now().UnixNano(), ext)
|
||
if err := s.put(ctx, key, body, ct, entity.MaxCoverBytes); err != nil {
|
||
return nil, err
|
||
}
|
||
old := t.CoverKey
|
||
t.CoverKey = key
|
||
t.CoverURL = s.publicURL(key)
|
||
if err := s.repo.Update(ctx, t); err != nil {
|
||
return nil, err
|
||
}
|
||
if old != "" && old != key {
|
||
_ = s.deleteObject(ctx, old)
|
||
}
|
||
return t, nil
|
||
}
|
||
|
||
func (s *Service) UploadAudio(ctx context.Context, id int64, filename string, body []byte, contentType string) (*entity.Track, error) {
|
||
if !s.Ready() {
|
||
return nil, ErrNotReady
|
||
}
|
||
if len(body) == 0 {
|
||
return nil, errs.ErrInvalidArgument.WithMsg("请选择 mp3 文件")
|
||
}
|
||
if len(body) > entity.MaxAudioBytes {
|
||
return nil, errs.ErrInvalidArgument.WithMsg("音频不能超过 50MB")
|
||
}
|
||
ct := normalizeMIME(contentType, filename)
|
||
if !isAudioMIME(ct, filename) {
|
||
return nil, errs.ErrInvalidArgument.WithMsg("音频仅支持 mp3(audio/mpeg)")
|
||
}
|
||
t, err := s.repo.Get(ctx, id)
|
||
if err != nil {
|
||
return nil, errs.ErrNotFound.WithMsg("曲目不存在")
|
||
}
|
||
ext := extFor(ct, filename, "mp3")
|
||
key := fmt.Sprintf("perm/music/audio/%d/%d.%s", id, time.Now().UnixNano(), ext)
|
||
if err := s.put(ctx, key, body, ct, entity.MaxAudioBytes); err != nil {
|
||
return nil, err
|
||
}
|
||
old := t.AudioKey
|
||
t.AudioKey = key
|
||
t.AudioURL = s.publicURL(key)
|
||
if err := s.repo.Update(ctx, t); err != nil {
|
||
return nil, err
|
||
}
|
||
if old != "" && old != key {
|
||
_ = s.deleteObject(ctx, old)
|
||
}
|
||
return t, nil
|
||
}
|
||
|
||
func (s *Service) put(ctx context.Context, key string, body []byte, contentType string, max int) error {
|
||
if s.objects == nil || !s.objects.Ready() {
|
||
return errs.ErrAvailable.WithMsg("对象存储未就绪")
|
||
}
|
||
return s.objects.PutLarge(ctx, key, body, contentType, max)
|
||
}
|
||
|
||
func (s *Service) deleteObject(ctx context.Context, key string) error {
|
||
if key == "" || s.objects == nil || !s.objects.Ready() {
|
||
return nil
|
||
}
|
||
return s.objects.Delete(ctx, key)
|
||
}
|
||
|
||
func normalizeMIME(raw, filename string) string {
|
||
s := strings.ToLower(strings.TrimSpace(raw))
|
||
s, _, _ = strings.Cut(s, ";")
|
||
s = strings.TrimSpace(s)
|
||
if s != "" && s != "application/octet-stream" {
|
||
return s
|
||
}
|
||
ext := strings.ToLower(strings.TrimPrefix(path.Ext(filename), "."))
|
||
switch ext {
|
||
case "jpg", "jpeg":
|
||
return "image/jpeg"
|
||
case "png":
|
||
return "image/png"
|
||
case "webp":
|
||
return "image/webp"
|
||
case "gif":
|
||
return "image/gif"
|
||
case "mp3":
|
||
return "audio/mpeg"
|
||
case "m4a":
|
||
return "audio/mp4"
|
||
default:
|
||
return s
|
||
}
|
||
}
|
||
|
||
func isImageMIME(ct string) bool {
|
||
switch ct {
|
||
case "image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func isAudioMIME(ct, filename string) bool {
|
||
switch ct {
|
||
case "audio/mpeg", "audio/mp3", "audio/x-mpeg":
|
||
return true
|
||
}
|
||
return strings.HasSuffix(strings.ToLower(filename), ".mp3")
|
||
}
|
||
|
||
func extFor(ct, filename, fallback string) string {
|
||
ext := strings.ToLower(strings.TrimPrefix(path.Ext(filename), "."))
|
||
if ext != "" && len(ext) <= 8 {
|
||
return ext
|
||
}
|
||
switch ct {
|
||
case "image/jpeg", "image/jpg":
|
||
return "jpg"
|
||
case "image/png":
|
||
return "png"
|
||
case "image/webp":
|
||
return "webp"
|
||
case "image/gif":
|
||
return "gif"
|
||
case "audio/mpeg", "audio/mp3":
|
||
return "mp3"
|
||
default:
|
||
return fallback
|
||
}
|
||
}
|