feat: finish the upload, playback, and test gaps from the docs
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>
This commit is contained in:
mrleo1nidandCursor committed 2026-10-21 09:37:53 +03:00
1 parent 2b76060ea3
commit 066ba24834
61 files changed
+2818 -463

No files matched your search

+141
View File
@@ -0,0 +1,141 @@
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())
}