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