Files
pipu-music/services/api/internal/uploads/batch.go
T
mrleo1nidandCursor 066ba24834
ci / api / api (push) Successful in 40s
ci / db / db (push) Successful in 1m3s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 1m48s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 46s
ci / ts / ts (packages + web) (push) Failing after 4m57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 5m7s
feat: finish the upload, playback, and test gaps from the docs
Quality upgrades, batch covers, staging preview, and resume after reload are in the queue. Loudness and crossfade work on web and Android, and the documented checks now have suites.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 09:37:53 +03:00

142 lines
3.6 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
// 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())
}