Files
pipu-music/services/api/internal/uploads/batch.go
T
mrleo1nid 64dd8ed05e
ci / module boundaries / boundaries (push) Successful in 7s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 55s
ci / ts / ts (packages + web) (push) Failing after 4m4s
ci / api / api (push) Successful in 55s
ci / db / db (push) Failing after 1m14s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m11s
feat(uploads): preview storage paths, detect templates, attach covers and withdraw batches
2026-10-29 10:28:29 +03:00

166 lines
4.4 KiB
Go

package uploads
import (
"context"
"encoding/base64"
"os"
"path"
"path/filepath"
"strings"
"github.com/google/uuid"
"pipu/api/internal/auth"
"pipu/api/internal/platform/apperr"
)
// BatchPatch — правка на всю пачку. Nil-поля не меняют файлы.
type BatchPatch struct {
Artist *string
Album *string
Year *int
SetYear bool
Cover []byte
ClearCover bool
}
const maxCoverBytes = 8 << 20
// createCover writes a new immutable cover before the quota transaction; Create removes it on failure.
func (s *Service) createCover(data []byte) (*string, error) {
if data == nil {
return nil, nil
}
if len(data) > maxCoverBytes {
return nil, apperr.Validation("cover_base64", "validation.cover_too_large", "cover is larger than 8 MB", nil)
}
ext, ok := coverExt(data)
if !ok {
return nil, apperr.Validation("cover_base64", "validation.cover_type", "cover must be jpeg, png or webp", nil)
}
rel := path.Join("covers", newID().String()+ext)
abs := s.stagingAbs(rel)
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
return nil, err
}
if err := os.WriteFile(abs, data, 0o644); err != nil {
_ = os.Remove(abs)
return nil, err
}
return &rel, nil
}
// UpdateBatch применяет исполнителя, альбом, год и обложку к файлам пачки на рассмотрении.
func (s *Service) UpdateBatch(ctx context.Context, id uuid.UUID, in BatchPatch) ([]File, error) {
if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
return nil, err
}
files, err := s.store.PendingInBatch(ctx, id)
if err != nil {
return nil, err
}
for _, f := range files {
m := Meta{}
if f.Meta != nil {
m = *f.Meta
}
if in.Artist != nil {
m.Artist = *in.Artist
m.AlbumArtist = *in.Artist
}
if in.Album != nil {
m.Album = *in.Album
}
if in.SetYear {
m.Year = in.Year
}
if m.Title == "" {
m.Title = strings.TrimSuffix(f.OriginalName, path.Ext(f.OriginalName))
}
if m.Artist == "" || m.Title == "" {
continue
}
if _, err := s.Update(ctx, f.ID, UpdateInput{Meta: &m}); err != nil {
return nil, err
}
}
if in.ClearCover || in.Cover != nil {
if err := s.writeCover(ctx, id, files, in); err != nil {
return nil, err
}
}
return s.store.PendingInBatch(ctx, id)
}
func (s *Service) writeCover(ctx context.Context, id uuid.UUID, files []File, in BatchPatch) error {
prev := ""
if len(files) > 0 && files[0].CoverPath != nil {
prev = *files[0].CoverPath
}
if in.ClearCover || in.Cover == nil {
if prev != "" {
_ = os.Remove(s.stagingAbs(prev))
}
return s.store.SetCover(ctx, id, nil)
}
if len(in.Cover) > maxCoverBytes {
return apperr.Validation("cover_base64", "validation.cover_too_large", "cover is larger than 8 MB", nil)
}
ext, ok := coverExt(in.Cover)
if !ok {
return apperr.Validation("cover_base64", "validation.cover_type", "cover must be jpeg, png or webp", nil)
}
rel := path.Join("covers", id.String()+ext)
abs := s.stagingAbs(rel)
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
return err
}
if err := os.WriteFile(abs, in.Cover, 0o644); err != nil {
return err
}
if prev != "" && prev != rel {
_ = os.Remove(s.stagingAbs(prev))
}
return s.store.SetCover(ctx, id, &rel)
}
func coverExt(b []byte) (string, bool) {
switch {
case len(b) >= 3 && b[0] == 0xff && b[1] == 0xd8 && b[2] == 0xff:
return ".jpg", true
case len(b) >= 8 && string(b[:8]) == "\x89PNG\r\n\x1a\n":
return ".png", true
case len(b) >= 12 && string(b[:4]) == "RIFF" && string(b[8:12]) == "WEBP":
return ".webp", true
default:
return "", false
}
}
// DecodeCover декодирует обложку из base64.
func DecodeCover(s string) ([]byte, error) {
b, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return nil, apperr.Validation("cover_base64", "validation.cover_type", "cover is not base64", nil)
}
return b, nil
}
// Unseen — сколько решений автор ещё не открывал.
func (s *Service) Unseen(ctx context.Context) (int, error) {
p, err := principal(ctx, auth.PermSuggestTracks)
if err != nil {
return 0, err
}
return s.store.UnseenDecisions(ctx, p.UserID)
}
// MarkSeen отмечает предложения просмотренными.
func (s *Service) MarkSeen(ctx context.Context) error {
p, err := principal(ctx, auth.PermSuggestTracks)
if err != nil {
return err
}
return s.store.MarkSeen(ctx, p.UserID, s.now())
}