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
166 lines
4.4 KiB
Go
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())
|
|
}
|