Reviewing the upload queue is now a per-user flag (reviews_uploads), not part of the admin role: several people can hold it, a moderator can have it, and an admin may not. Only admins grant or revoke it; existing admins keep it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
342 lines
12 KiB
Go
342 lines
12 KiB
Go
package uploads
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"pipu/api/internal/api/gen"
|
|
"pipu/api/internal/auth"
|
|
"pipu/api/internal/platform/apperr"
|
|
"pipu/api/internal/platform/sign"
|
|
)
|
|
|
|
// Handler — эндпоинты очереди на добавление (часть gen.StrictServerInterface).
|
|
type Handler struct {
|
|
svc *Service
|
|
signer *sign.Signer
|
|
xAccel bool
|
|
}
|
|
|
|
// NewHandler создаёт обработчик. signer подписывает ссылки прослушивания из staging.
|
|
func NewHandler(svc *Service, signer *sign.Signer, xAccel bool) *Handler {
|
|
return &Handler{svc: svc, signer: signer, xAccel: xAccel}
|
|
}
|
|
|
|
func (h *Handler) fileDTO(ctx context.Context, f File) gen.UploadFile {
|
|
out := gen.UploadFile{
|
|
Id: f.ID, BatchId: f.BatchID, BatchComment: f.BatchComment,
|
|
Author: gen.UserRef{Id: f.AuthorID, Username: f.AuthorName},
|
|
Status: gen.UploadStatus(f.Status), OriginalName: f.OriginalName, ClientRelPath: f.ClientRelPath,
|
|
SizeBytes: f.Size, ReceivedBytes: f.Received, Format: f.Format, Codec: f.Codec, Bitrate: f.Bitrate,
|
|
SampleRate: f.SampleRate, BitDepth: f.BitDepth, DurationMs: f.DurationMs, Tags: f.Tags,
|
|
MetadataEdited: f.MetadataEdited, MatchedArtistId: f.MatchedArtistID, MatchedAlbumId: f.MatchedAlbumID,
|
|
DuplicateOfTrackId: f.DuplicateOfTrackID, PossibleDuplicate: f.PossibleDuplicate, QualityUpgrade: f.QualityUpgrade,
|
|
TargetSourceId: f.TargetSourceID, TargetRelPath: f.TargetRelPath, StatusReason: f.StatusReason,
|
|
ReviewedAt: f.ReviewedAt, CreatedAt: f.CreatedAt, UpdatedAt: f.UpdatedAt,
|
|
}
|
|
if out.Tags == nil {
|
|
out.Tags = map[string]string{}
|
|
}
|
|
if f.DuplicateLevel != nil {
|
|
l := gen.UploadFileDuplicateLevel(*f.DuplicateLevel)
|
|
out.DuplicateLevel = &l
|
|
}
|
|
if f.Meta != nil {
|
|
m := metaDTO(*f.Meta)
|
|
out.Metadata = &m
|
|
}
|
|
if p, ok := auth.PrincipalFrom(ctx); ok && p.UserID == f.AuthorID && f.Status == StatusUploading {
|
|
u := TusURL(f.ID)
|
|
out.TusUrl = &u
|
|
}
|
|
if h.signer != nil && f.StagingPath != "" && f.Status.KeepsStaging() {
|
|
if p, ok := auth.PrincipalFrom(ctx); ok && p.ReviewsUploads {
|
|
u := h.streamURL(f.ID)
|
|
out.StreamUrl = &u
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func metaDTO(m Meta) gen.UploadMeta {
|
|
return gen.UploadMeta{
|
|
Artist: m.Artist, Albumartist: m.AlbumArtist, Album: m.Album, Title: m.Title, Genre: m.Genre,
|
|
Year: m.Year, Track: m.Track, Disc: m.Disc,
|
|
}
|
|
}
|
|
|
|
func metaFromDTO(m gen.UploadMeta) Meta {
|
|
return Meta{
|
|
Artist: m.Artist, AlbumArtist: m.Albumartist, Album: m.Album, Title: m.Title, Genre: m.Genre,
|
|
Year: m.Year, Track: m.Track, Disc: m.Disc,
|
|
}
|
|
}
|
|
|
|
func (h *Handler) pageDTO(ctx context.Context, files []File, next string) gen.UploadFilePage {
|
|
out := gen.UploadFilePage{Items: make([]gen.UploadFile, 0, len(files))}
|
|
for _, f := range files {
|
|
out.Items = append(out.Items, h.fileDTO(ctx, f))
|
|
}
|
|
if next != "" {
|
|
out.NextCursor = &next
|
|
}
|
|
return out
|
|
}
|
|
|
|
func statuses(in *gen.UploadStatusFilter) []Status {
|
|
if in == nil {
|
|
return nil
|
|
}
|
|
out := make([]Status, 0, len(*in))
|
|
for _, s := range *in {
|
|
out = append(out, Status(s))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func deref[T any](p *T, def T) T {
|
|
if p == nil {
|
|
return def
|
|
}
|
|
return *p
|
|
}
|
|
|
|
func settingsDTO(s Settings) gen.UploadSettings {
|
|
return gen.UploadSettings{
|
|
QueueLimitBytes: s.QueueLimitBytes, PerUserLimitBytes: s.PerUserLimitBytes, MaxFileBytes: s.MaxFileBytes,
|
|
DefaultSourceId: s.DefaultSourceID, AllowQualityUpgrade: s.AllowQualityUpgrade,
|
|
}
|
|
}
|
|
|
|
// GetUploadQuota реализует gen.StrictServerInterface.
|
|
func (h *Handler) GetUploadQuota(ctx context.Context, _ gen.GetUploadQuotaRequestObject) (gen.GetUploadQuotaResponseObject, error) {
|
|
q, err := h.svc.Quota(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return gen.GetUploadQuota200JSONResponse{
|
|
LimitBytes: q.QueueLimitBytes, UsedBytes: q.UsedBytes, PerUserLimitBytes: q.PerUserLimitBytes,
|
|
UserUsedBytes: q.UserUsedBytes, MaxFileBytes: q.MaxFileBytes,
|
|
}, nil
|
|
}
|
|
|
|
// PrecheckUploads реализует gen.StrictServerInterface.
|
|
func (h *Handler) PrecheckUploads(ctx context.Context, req gen.PrecheckUploadsRequestObject) (gen.PrecheckUploadsResponseObject, error) {
|
|
in := make([]PrecheckFile, 0, len(req.Body.Files))
|
|
for _, f := range req.Body.Files {
|
|
in = append(in, PrecheckFile{
|
|
ClientID: f.ClientId, Size: f.Size, SHA256: f.Sha256, Artist: deref(f.Artist, ""), Title: deref(f.Title, ""),
|
|
DurationMs: deref(f.DurationMs, 0),
|
|
})
|
|
}
|
|
res, err := h.svc.Precheck(ctx, in)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := gen.PrecheckUploads200JSONResponse{}
|
|
for _, r := range res {
|
|
out.Files = append(out.Files, struct {
|
|
ClientId string `json:"client_id"`
|
|
PossibleDuplicate bool `json:"possible_duplicate"`
|
|
Result gen.UploadPrecheckResultFilesResult `json:"result"`
|
|
TrackId *uuid.UUID `json:"track_id"`
|
|
}{ClientId: r.ClientID, PossibleDuplicate: r.Possible, Result: gen.UploadPrecheckResultFilesResult(r.Result), TrackId: r.Track})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// CreateUploadBatch реализует gen.StrictServerInterface.
|
|
func (h *Handler) CreateUploadBatch(ctx context.Context, req gen.CreateUploadBatchRequestObject) (gen.CreateUploadBatchResponseObject, error) {
|
|
in := make([]CreateFile, 0, len(req.Body.Files))
|
|
for _, f := range req.Body.Files {
|
|
in = append(in, CreateFile{ClientID: f.ClientId, Name: f.Name, RelPath: f.RelPath, Size: f.Size, SHA256: f.Sha256})
|
|
}
|
|
batch, res, err := h.svc.Create(ctx, req.Body.Comment, in)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := gen.CreateUploadBatch201JSONResponse{BatchId: batch}
|
|
for _, r := range res {
|
|
item := struct {
|
|
ClientId string `json:"client_id"`
|
|
Rejected *string `json:"rejected"`
|
|
TrackId *uuid.UUID `json:"track_id"`
|
|
TusUrl *string `json:"tus_url"`
|
|
UploadFileId *uuid.UUID `json:"upload_file_id"`
|
|
}{ClientId: r.ClientID, TrackId: r.Track}
|
|
if r.Rejected != "" {
|
|
code := r.Rejected
|
|
item.Rejected = &code
|
|
} else {
|
|
id, u := r.FileID, TusURL(r.FileID)
|
|
item.UploadFileId, item.TusUrl = &id, &u
|
|
}
|
|
out.Files = append(out.Files, item)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ListMyUploads реализует gen.StrictServerInterface.
|
|
func (h *Handler) ListMyUploads(ctx context.Context, req gen.ListMyUploadsRequestObject) (gen.ListMyUploadsResponseObject, error) {
|
|
files, next, err := h.svc.ListMine(ctx, statuses(req.Params.Status), deref(req.Params.Cursor, ""), deref(req.Params.Limit, 50))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return gen.ListMyUploads200JSONResponse(h.pageDTO(ctx, files, next)), nil
|
|
}
|
|
|
|
// WithdrawUpload реализует gen.StrictServerInterface.
|
|
func (h *Handler) WithdrawUpload(ctx context.Context, req gen.WithdrawUploadRequestObject) (gen.WithdrawUploadResponseObject, error) {
|
|
if err := h.svc.Withdraw(ctx, req.Id); err != nil {
|
|
return nil, err
|
|
}
|
|
return gen.WithdrawUpload204Response{}, nil
|
|
}
|
|
|
|
// ListUploads реализует gen.StrictServerInterface.
|
|
func (h *Handler) ListUploads(ctx context.Context, req gen.ListUploadsRequestObject) (gen.ListUploadsResponseObject, error) {
|
|
p := req.Params
|
|
files, next, err := h.svc.ListAll(ctx, ListFilter{
|
|
AuthorID: p.Author, BatchID: p.Batch, Statuses: statuses(p.Status), PossibleDuplicate: p.PossibleDuplicate,
|
|
Cursor: deref(p.Cursor, ""), Limit: deref(p.Limit, 50),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return gen.ListUploads200JSONResponse(h.pageDTO(ctx, files, next)), nil
|
|
}
|
|
|
|
// UpdateUpload реализует gen.StrictServerInterface.
|
|
func (h *Handler) UpdateUpload(ctx context.Context, req gen.UpdateUploadRequestObject) (gen.UpdateUploadResponseObject, error) {
|
|
in := UpdateInput{TargetSourceID: req.Body.TargetSourceId}
|
|
if req.Body.Metadata != nil {
|
|
m := metaFromDTO(*req.Body.Metadata)
|
|
in.Meta = &m
|
|
}
|
|
f, err := h.svc.Update(ctx, req.Id, in)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return gen.UpdateUpload200JSONResponse(h.fileDTO(ctx, f)), nil
|
|
}
|
|
|
|
// RetryUploadImport реализует gen.StrictServerInterface.
|
|
func (h *Handler) RetryUploadImport(ctx context.Context, req gen.RetryUploadImportRequestObject) (gen.RetryUploadImportResponseObject, error) {
|
|
f, err := h.svc.Retry(ctx, req.Id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return gen.RetryUploadImport200JSONResponse(h.fileDTO(ctx, f)), nil
|
|
}
|
|
|
|
// ReviewUploads реализует gen.StrictServerInterface.
|
|
func (h *Handler) ReviewUploads(ctx context.Context, req gen.ReviewUploadsRequestObject) (gen.ReviewUploadsResponseObject, error) {
|
|
res, err := h.svc.Review(ctx, req.Body.FileIds, req.Body.Decision == "approve", deref(req.Body.Reason, ""))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := gen.ReviewUploads200JSONResponse{}
|
|
for _, r := range res {
|
|
item := struct {
|
|
Error *gen.Problem `json:"error"`
|
|
Id uuid.UUID `json:"id"`
|
|
Ok bool `json:"ok"`
|
|
}{Id: r.ID, Ok: r.Error == nil}
|
|
if r.Error != nil {
|
|
item.Error = problem(r.Error)
|
|
}
|
|
out.Items = append(out.Items, item)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// problem — ошибка решения по одному файлу в виде Problem (массовое действие не падает целиком).
|
|
func problem(err error) *gen.Problem {
|
|
var ae *apperr.Error
|
|
if !errors.As(err, &ae) {
|
|
return &gen.Problem{Status: http.StatusInternalServerError, Code: "common.internal"}
|
|
}
|
|
p := &gen.Problem{Status: ae.Status, Code: ae.Code}
|
|
if len(ae.Params) > 0 {
|
|
params := map[string]any(ae.Params)
|
|
p.Params = ¶ms
|
|
}
|
|
return p
|
|
}
|
|
|
|
// GetUploadSettings реализует gen.StrictServerInterface.
|
|
func (h *Handler) GetUploadSettings(ctx context.Context, _ gen.GetUploadSettingsRequestObject) (gen.GetUploadSettingsResponseObject, error) {
|
|
s, err := h.svc.GetSettings(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return gen.GetUploadSettings200JSONResponse(settingsDTO(s)), nil
|
|
}
|
|
|
|
// UpdateUploadSettings реализует gen.StrictServerInterface.
|
|
func (h *Handler) UpdateUploadSettings(ctx context.Context, req gen.UpdateUploadSettingsRequestObject) (gen.UpdateUploadSettingsResponseObject, error) {
|
|
b := req.Body
|
|
s, err := h.svc.UpdateSettings(ctx, SettingsInput{
|
|
QueueLimitBytes: b.QueueLimitBytes, MaxFileBytes: b.MaxFileBytes,
|
|
PerUserLimitBytes: b.PerUserLimitBytes, ClearPerUser: b.PerUserLimitBytes == nil,
|
|
DefaultSourceID: b.DefaultSourceId, ClearDefault: b.DefaultSourceId == nil,
|
|
AllowQualityUpgrade: b.AllowQualityUpgrade,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return gen.UpdateUploadSettings200JSONResponse(settingsDTO(s)), nil
|
|
}
|
|
|
|
// UnseenUploadDecisions реализует gen.StrictServerInterface.
|
|
func (h *Handler) UnseenUploadDecisions(ctx context.Context, _ gen.UnseenUploadDecisionsRequestObject) (gen.UnseenUploadDecisionsResponseObject, error) {
|
|
n, err := h.svc.Unseen(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return gen.UnseenUploadDecisions200JSONResponse{Count: n}, nil
|
|
}
|
|
|
|
// MarkUploadsSeen реализует gen.StrictServerInterface.
|
|
func (h *Handler) MarkUploadsSeen(ctx context.Context, _ gen.MarkUploadsSeenRequestObject) (gen.MarkUploadsSeenResponseObject, error) {
|
|
if err := h.svc.MarkSeen(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
return gen.MarkUploadsSeen204Response{}, nil
|
|
}
|
|
|
|
// UpdateUploadBatch реализует gen.StrictServerInterface.
|
|
func (h *Handler) UpdateUploadBatch(ctx context.Context, req gen.UpdateUploadBatchRequestObject) (gen.UpdateUploadBatchResponseObject, error) {
|
|
b := req.Body
|
|
in := BatchPatch{}
|
|
if b.Artist != nil {
|
|
in.Artist = b.Artist
|
|
}
|
|
if b.Album != nil {
|
|
in.Album = b.Album
|
|
}
|
|
if b.Year != nil {
|
|
in.SetYear = true
|
|
in.Year = b.Year
|
|
}
|
|
if b.CoverBase64 != nil {
|
|
if *b.CoverBase64 == "" {
|
|
in.ClearCover = true
|
|
} else {
|
|
raw, err := DecodeCover(*b.CoverBase64)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
in.Cover = raw
|
|
}
|
|
}
|
|
files, err := h.svc.UpdateBatch(ctx, req.Id, in)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return gen.UpdateUploadBatch200JSONResponse{Items: h.pageDTO(ctx, files, "").Items}, nil
|
|
}
|