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()) }