feat(api): upload queue with quota, tus intake, processing and import (M5.5)
ci / api / api (push) Successful in 46s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m3s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m22s
ci / ts / ts (packages + web) (push) Successful in 1m38s

- Migration 00008: upload_batches, upload_files, upload_quota.
- Quota reserved under a row lock when a batch is created; every status
  change goes through one transition that frees space in the same
  transaction. SHA-256 duplicates are rejected before any upload.
- tus 1.0.0 core (HEAD/PATCH) for the author only; offset is the real
  staging file size, data beyond the declared size is refused (ADR-015).
- Processing worker: ffprobe checks, SHA-256 verification, exact and
  Chromaprint duplicates against library and queue, metadata from tags
  or the dropped folder path, possible_duplicate on metadata match, and
  the target path from write_template reusing existing folder spelling.
- Import worker: edited tags written with ffmpeg stream copy, file
  written by storage-agent, point scan of the new file, imported status;
  idempotent and resumed after restart. Stale uploads expire after 24 h,
  orphan staging files are removed at startup.
- API: quota, precheck, batches, my uploads, withdraw, admin list,
  metadata edit, bulk review, retry and queue settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 17:23:05 +03:00
1 parent f66be2ba0a
commit ca106e8eb1
21 files changed
+5552 -198

No files matched your search

+664
View File
@@ -0,0 +1,664 @@
package uploads
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"pipu/api/internal/auth"
"pipu/api/internal/library"
"pipu/api/internal/library/meta"
"pipu/api/internal/platform/apperr"
)
// Config — пути и бинарники очереди.
type Config struct {
StagingDir string // каталог staging (тот же, что /uploads у storage-agent)
FFmpeg string // запись правленых тегов при импорте
}
// Service — очередь на добавление.
type Service struct {
store *Store
lib *library.Store
libSvc *library.Service
scanner *library.Scanner
agent library.Agent
prober library.Prober
fpcalc library.FPCalc
cfg Config
log *slog.Logger
wakeProcess chan struct{}
wakeImport chan struct{}
now func() time.Time
}
// NewService создаёт сервис очереди.
func NewService(store *Store, lib *library.Store, libSvc *library.Service, scanner *library.Scanner, agent library.Agent,
prober library.Prober, fpcalc library.FPCalc, cfg Config, log *slog.Logger) *Service {
return &Service{
store: store, lib: lib, libSvc: libSvc, scanner: scanner, agent: agent, prober: prober, fpcalc: fpcalc, cfg: cfg,
log: log, wakeProcess: make(chan struct{}, 1), wakeImport: make(chan struct{}, 1),
now: func() time.Time { return time.Now().UTC() },
}
}
func wake(ch chan struct{}) {
select {
case ch <- struct{}{}:
default:
}
}
func principal(ctx context.Context, perm auth.Permission) (auth.Principal, error) {
p, ok := auth.PrincipalFrom(ctx)
if !ok {
return auth.Principal{}, apperr.Unauthorized(auth.CodeUnauthorized, "not authenticated")
}
if !auth.Can(p.Role, perm) {
return auth.Principal{}, apperr.Forbidden(auth.CodeForbidden, "not allowed")
}
return p, nil
}
func notFound(err error) error {
if errors.Is(err, ErrNotFound) {
return apperr.NotFound(CodeNotFound, "upload not found")
}
if errors.Is(err, ErrWrongStatus) {
return apperr.Conflict(CodeWrongStatus, "upload is in another status")
}
return err
}
// --- Квота и предпроверка ---
// Quota — лимиты и занятое место (видят модераторы и админ).
type Quota struct {
Settings
UsedBytes int64
UserUsedBytes int64
}
// Quota — состояние очереди для текущего пользователя.
func (s *Service) Quota(ctx context.Context) (Quota, error) {
p, err := principal(ctx, auth.PermSuggestTracks)
if err != nil {
return Quota{}, err
}
st, err := s.store.Settings(ctx)
if err != nil {
return Quota{}, err
}
used, userUsed, err := s.store.Usage(ctx, p.UserID)
if err != nil {
return Quota{}, err
}
return Quota{Settings: st, UsedBytes: used, UserUsedBytes: userUsed}, nil
}
// PrecheckFile — файл перед загрузкой: хэш и теги считает клиент.
type PrecheckFile struct {
ClientID string
Size int64
SHA256 string
Artist string
Title string
DurationMs int
}
// PrecheckResult — итог по файлу: accepted / duplicate / too_large; possible — совпали только метаданные.
type PrecheckResult struct {
ClientID string
Result string
Track *uuid.UUID
Possible bool
}
// Precheck отсеивает дубли до загрузки (ничего не резервирует, docs/uploads.md#4).
func (s *Service) Precheck(ctx context.Context, files []PrecheckFile) ([]PrecheckResult, error) {
if _, err := principal(ctx, auth.PermSuggestTracks); err != nil {
return nil, err
}
st, err := s.store.Settings(ctx)
if err != nil {
return nil, err
}
out := make([]PrecheckResult, 0, len(files))
seen := map[string]bool{}
for _, f := range files {
r := PrecheckResult{ClientID: f.ClientID, Result: "accepted"}
sum, _ := hex.DecodeString(f.SHA256)
switch {
case f.Size > st.MaxFileBytes:
r.Result = "too_large"
case len(sum) == 32 && seen[string(sum)]:
r.Result = "duplicate"
default:
track, dup, err := s.store.DuplicateBySHA(ctx, sum, uuid.Nil)
if err != nil {
return nil, err
}
if dup {
r.Result, r.Track = "duplicate", track
} else if f.Artist != "" && f.Title != "" && f.DurationMs > 0 {
track, err := s.store.MetadataMatch(ctx, meta.Key(f.Artist), meta.Key(f.Title), f.DurationMs, 2000)
if err != nil {
return nil, err
}
r.Possible, r.Track = track != nil, track
}
}
if len(sum) == 32 {
seen[string(sum)] = true
}
out = append(out, r)
}
return out, nil
}
// --- Создание пачки ---
// CreateFile — файл новой пачки.
type CreateFile struct {
ClientID string
Name string
RelPath *string
Size int64
SHA256 string
}
// Create создаёт пачку и резервирует место; не поместившиеся и дубли отклоняются с кодом.
func (s *Service) Create(ctx context.Context, comment *string, files []CreateFile) (uuid.UUID, []Reserved, error) {
p, err := principal(ctx, auth.PermSuggestTracks)
if err != nil {
return uuid.Nil, nil, err
}
if len(files) == 0 || len(files) > 500 {
return uuid.Nil, nil, apperr.Validation("files", "validation.files_count", "1..500 files", map[string]any{"max": 500})
}
st, err := s.store.Settings(ctx)
if err != nil {
return uuid.Nil, nil, err
}
var (
accepted []NewFile
rejected []Reserved
)
for _, f := range files {
sum, err := hex.DecodeString(f.SHA256)
name := path.Base(strings.ReplaceAll(f.Name, "\\", "/"))
ext := strings.TrimPrefix(strings.ToLower(path.Ext(name)), ".")
switch {
case err != nil || len(sum) != 32:
return uuid.Nil, nil, apperr.Validation("sha256", "validation.sha256_invalid", "sha256 must be 64 hex characters", nil)
case f.Size <= 0:
return uuid.Nil, nil, apperr.Validation("size", "validation.size_invalid", "size must be positive", nil)
case !AllowedExtensions[ext]:
rejected = append(rejected, Reserved{ClientID: f.ClientID, Rejected: CodeBadExtension})
continue
}
accepted = append(accepted, NewFile{ClientID: f.ClientID, Name: name, RelPath: cleanClientPath(f.RelPath), Size: f.Size, SHA256: sum})
}
batch, res, err := s.store.CreateBatch(ctx, p.UserID, comment, accepted, st)
if err != nil {
return uuid.Nil, nil, err
}
return batch, append(res, rejected...), nil
}
// cleanClientPath — относительный путь из перетащенной папки (только для подсказки метаданных).
func cleanClientPath(p *string) *string {
if p == nil {
return nil
}
v := strings.Trim(strings.ReplaceAll(*p, "\\", "/"), "/")
if v == "" || len(v) > 1000 {
return nil
}
return &v
}
// Complete — tus принял файл целиком: в обработку.
func (s *Service) Complete(ctx context.Context, f File) error {
err := s.store.Transition(ctx, f.ID, []Status{StatusUploading}, StatusProcessing, nil)
if err == nil {
wake(s.wakeProcess)
}
return err
}
// --- Списки ---
// ListMine — предложения текущего пользователя.
func (s *Service) ListMine(ctx context.Context, statuses []Status, cursor string, limit int) ([]File, string, error) {
p, err := principal(ctx, auth.PermSuggestTracks)
if err != nil {
return nil, "", err
}
return s.list(ctx, ListFilter{AuthorID: &p.UserID, Statuses: statuses, Cursor: cursor, Limit: limit})
}
// ListAll — вся очередь (админ).
func (s *Service) ListAll(ctx context.Context, fl ListFilter) ([]File, string, error) {
if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
return nil, "", err
}
return s.list(ctx, fl)
}
func (s *Service) list(ctx context.Context, fl ListFilter) ([]File, string, error) {
files, next, err := s.store.List(ctx, fl)
if err != nil && strings.Contains(err.Error(), "bad cursor") {
return nil, "", apperr.Validation("cursor", "validation.cursor_invalid", "bad cursor", nil)
}
return files, next, err
}
// --- Действия автора ---
// Withdraw — автор отзывает файл до решения.
func (s *Service) Withdraw(ctx context.Context, id uuid.UUID) error {
p, err := principal(ctx, auth.PermSuggestTracks)
if err != nil {
return err
}
f, err := s.store.File(ctx, id)
if err != nil || f.AuthorID != p.UserID {
return apperr.NotFound(CodeNotFound, "upload not found")
}
if err := s.store.Transition(ctx, id, []Status{StatusUploading, StatusProcessing, StatusPendingReview}, StatusWithdrawn, nil); err != nil {
return notFound(err)
}
s.removeStaging(f)
return nil
}
// --- Админ ---
// UpdateInput — правка файла админом. nil — не менять.
type UpdateInput struct {
Meta *Meta
TargetSourceID *uuid.UUID
}
// Update правит метаданные и хранилище; путь в хранилище пересчитывается.
func (s *Service) Update(ctx context.Context, id uuid.UUID, in UpdateInput) (File, error) {
if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
return File{}, err
}
f, err := s.store.File(ctx, id)
if err != nil {
return File{}, notFound(err)
}
if f.Status != StatusPendingReview && f.Status != StatusImportFailed {
return File{}, apperr.Conflict(CodeWrongStatus, "only files waiting for review can be edited")
}
m := Meta{}
if f.Meta != nil {
m = *f.Meta
}
edited := f.MetadataEdited
if in.Meta != nil {
next := normalizeMeta(*in.Meta)
if next.Title == "" || next.Artist == "" {
return File{}, apperr.Validation("metadata", CodeMissingField, "artist and title are required", nil)
}
edited = edited || next != m
m = next
}
target := f.TargetSourceID
if in.TargetSourceID != nil {
target = in.TargetSourceID
}
pl := s.plan(ctx, f, m, target)
raw, _ := json.Marshal(m)
_, err = s.store.pool.Exec(ctx, `
UPDATE upload_files SET metadata = $2, metadata_edited = $3, target_source_id = $4, target_rel_path = $5,
status_reason = $6, matched_artist_id = $7, matched_album_id = $8, updated_at = now()
WHERE id = $1 AND status IN ('pending_review', 'import_failed')`,
id, raw, edited, pl.SourceID, pl.RelPath, nullable(pl.Reason), pl.ArtistID, pl.AlbumID)
if err != nil {
return File{}, fmt.Errorf("uploads: update: %w", err)
}
return s.store.File(ctx, id)
}
func normalizeMeta(m Meta) Meta {
m.Artist, m.AlbumArtist, m.Album = meta.Clean(m.Artist), meta.Clean(m.AlbumArtist), meta.Clean(m.Album)
m.Title, m.Genre = meta.Clean(m.Title), meta.Clean(m.Genre)
if m.AlbumArtist == "" {
m.AlbumArtist = m.Artist
}
for _, p := range []**int{&m.Year, &m.Track, &m.Disc} {
if *p != nil && **p <= 0 {
*p = nil
}
}
return m
}
func nullable(s string) *string {
if s == "" {
return nil
}
return &s
}
// ReviewResult — итог решения по одному файлу.
type ReviewResult struct {
ID uuid.UUID
Error error
}
// Review — массовое решение: approve / reject (с причиной, её видит автор).
func (s *Service) Review(ctx context.Context, ids []uuid.UUID, approve bool, reason string) ([]ReviewResult, error) {
p, err := principal(ctx, auth.PermReviewUploads)
if err != nil {
return nil, err
}
reason = strings.TrimSpace(reason)
out := make([]ReviewResult, 0, len(ids))
for _, id := range ids {
out = append(out, ReviewResult{ID: id, Error: s.reviewOne(ctx, p.UserID, id, approve, reason)})
}
if approve {
wake(s.wakeImport)
}
return out, nil
}
func (s *Service) reviewOne(ctx context.Context, reviewer, id uuid.UUID, approve bool, reason string) error {
f, err := s.store.File(ctx, id)
if err != nil {
return notFound(err)
}
mark := func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `UPDATE upload_files SET reviewed_by = $2, reviewed_at = now(), status_reason = $3 WHERE id = $1`,
id, reviewer, nullable(reason))
return err
}
if !approve {
if err := s.store.Transition(ctx, id, []Status{StatusPendingReview, StatusImportFailed}, StatusRejected, mark); err != nil {
return notFound(err)
}
s.removeStaging(f)
s.store.Audit(ctx, &reviewer, "upload.reject", id, map[string]any{"reason": reason})
return nil
}
if f.Meta == nil {
return apperr.Conflict(CodeMissingField, "file has no metadata")
}
pl := s.plan(ctx, f, *f.Meta, f.TargetSourceID)
if pl.Reason != "" {
return apperr.Conflict(pl.Reason, "cannot place the file into a storage")
}
reason = ""
if err := s.store.Transition(ctx, id, []Status{StatusPendingReview}, StatusApproved, mark); err != nil {
return notFound(err)
}
s.store.Audit(ctx, &reviewer, "upload.approve", id, map[string]any{"target": pl.RelPath})
return nil
}
// Retry — повторить импорт после import_failed.
func (s *Service) Retry(ctx context.Context, id uuid.UUID) (File, error) {
if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
return File{}, err
}
if err := s.store.Transition(ctx, id, []Status{StatusImportFailed}, StatusApproved, nil); err != nil {
return File{}, notFound(err)
}
wake(s.wakeImport)
return s.store.File(ctx, id)
}
// GetSettings — настройки очереди (админ).
func (s *Service) GetSettings(ctx context.Context) (Settings, error) {
if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
return Settings{}, err
}
return s.store.Settings(ctx)
}
// SettingsInput — правка настроек; nil — не менять. ClearPerUser / ClearDefault — сбросить.
type SettingsInput struct {
QueueLimitBytes *int64
PerUserLimitBytes *int64
MaxFileBytes *int64
DefaultSourceID *uuid.UUID
ClearPerUser bool
ClearDefault bool
}
// UpdateSettings меняет лимиты; уменьшение ниже занятого ничего не удаляет (docs/uploads.md#3).
func (s *Service) UpdateSettings(ctx context.Context, in SettingsInput) (Settings, error) {
p, err := principal(ctx, auth.PermReviewUploads)
if err != nil {
return Settings{}, err
}
st, err := s.store.Settings(ctx)
if err != nil {
return Settings{}, err
}
if in.QueueLimitBytes != nil {
if *in.QueueLimitBytes < 0 {
return Settings{}, apperr.Validation("queue_limit_bytes", "validation.limit_invalid", "must be ≥ 0", nil)
}
st.QueueLimitBytes = *in.QueueLimitBytes
}
if in.MaxFileBytes != nil {
if *in.MaxFileBytes <= 0 {
return Settings{}, apperr.Validation("max_file_bytes", "validation.limit_invalid", "must be > 0", nil)
}
st.MaxFileBytes = *in.MaxFileBytes
}
switch {
case in.ClearPerUser:
st.PerUserLimitBytes = nil
case in.PerUserLimitBytes != nil:
if *in.PerUserLimitBytes <= 0 {
return Settings{}, apperr.Validation("per_user_limit_bytes", "validation.limit_invalid", "must be > 0", nil)
}
st.PerUserLimitBytes = in.PerUserLimitBytes
}
switch {
case in.ClearDefault:
st.DefaultSourceID = nil
case in.DefaultSourceID != nil:
src, err := s.lib.GetSource(ctx, *in.DefaultSourceID)
if err != nil || !src.AcceptsUploads {
return Settings{}, apperr.Validation("default_source_id", CodeNoTarget, "storage must accept uploads", nil)
}
st.DefaultSourceID = in.DefaultSourceID
}
if err := s.store.SaveSettings(ctx, st); err != nil {
return Settings{}, err
}
s.store.Audit(ctx, &p.UserID, "uploads.settings", uuid.Nil, map[string]any{"settings": st})
return st, nil
}
// --- Раскладка в хранилище ---
// placement — куда ляжет файл; Reason — почему не получится (код), путь тогда nil.
type placement struct {
SourceID *uuid.UUID
RelPath *string
Reason string
ArtistID *uuid.UUID
AlbumID *uuid.UUID
src library.Source
root string
}
// pickSource — хранилище: выбранное админом → по умолчанию → единственное принимающее загрузки.
func (s *Service) pickSource(ctx context.Context, chosen *uuid.UUID) (library.Source, string) {
id := chosen
if id == nil {
st, err := s.store.Settings(ctx)
if err == nil {
id = st.DefaultSourceID
}
}
if id != nil {
src, err := s.lib.GetSource(ctx, *id)
if err != nil || !src.AcceptsUploads {
return library.Source{}, CodeNoTarget
}
return src, ""
}
sources, err := s.lib.ListSources(ctx)
if err != nil {
return library.Source{}, CodeNoTarget
}
var found []library.Source
for _, src := range sources {
if src.AcceptsUploads {
found = append(found, src)
}
}
if len(found) != 1 {
return library.Source{}, CodeNoTarget
}
return found[0], ""
}
// plan считает целевой путь по write_template хранилища; существующие папки исполнителя / альбома
// переиспользуются в их написании (docs/uploads.md#очистка-имён).
func (s *Service) plan(ctx context.Context, f File, m Meta, chosen *uuid.UUID) placement {
var (
pl placement
discs int // дисков в альбоме: в каталоге, в пачке, у самого файла
)
if artist, album, maxDisc, err := s.store.MatchCatalog(ctx, meta.Key(m.AlbumArtist), meta.Key(m.Album)); err == nil {
pl.ArtistID, pl.AlbumID, discs = artist, album, maxDisc
}
src, reason := s.pickSource(ctx, chosen)
if reason != "" {
pl.Reason = reason
return pl
}
pl.SourceID, pl.src = &src.ID, src
if src.WriteTemplate == nil {
pl.Reason = CodeTemplate
return pl
}
tpl, err := meta.ParseTemplate(*src.WriteTemplate)
if err != nil {
pl.Reason = CodeTemplate
return pl
}
if n, err := s.store.BatchMaxDisc(ctx, f.BatchID, m.Album); err == nil {
discs = max(discs, n)
}
if m.Disc != nil {
discs = max(discs, *m.Disc)
}
rel, err := tpl.Render(meta.TemplateInput{Fields: m.Fields(), DiscCount: discs, Ext: extFor(f)})
if err != nil {
var mf *meta.MissingFieldError
if errors.As(err, &mf) {
pl.Reason = CodeMissingField
} else {
pl.Reason = CodeTemplate
}
return pl
}
root, err := s.libSvc.Root(src)
if err != nil {
pl.Reason = CodeNoTarget
return pl
}
rel = reuseExisting(root, rel)
pl.RelPath, pl.root = &rel, root
return pl
}
// extFor — расширение по реальному формату (кодек из ffprobe), иначе — по имени файла.
func extFor(f File) string {
orig := strings.TrimPrefix(strings.ToLower(path.Ext(f.OriginalName)), ".")
if f.Codec == nil {
return orig
}
switch c := *f.Codec; {
case c == "flac":
return "flac"
case c == "mp3":
return "mp3"
case c == "aac" || c == "alac":
return "m4a"
case c == "vorbis":
return "ogg"
case c == "opus":
return "opus"
case c == "wavpack":
return "wv"
case strings.HasPrefix(c, "pcm_"):
if orig == "aiff" || orig == "aif" {
return orig
}
return "wav"
}
return orig
}
// reuseExisting заменяет сегменты каталогов на уже существующие с тем же ключом имени («КИНО» → «Кино»).
// Сравнение по списку каталога, а не os.Stat: на ФС без учёта регистра Stat нашёл бы папку, не дав её написания.
func reuseExisting(root, rel string) string {
segs := strings.Split(rel, "/")
cur := root
for i := 0; i < len(segs)-1; i++ {
entries, err := os.ReadDir(cur)
if err != nil {
break
}
key, match := meta.Key(segs[i]), ""
for _, e := range entries {
if !e.IsDir() {
continue
}
if e.Name() == segs[i] {
match = e.Name()
break
}
if match == "" && meta.Key(e.Name()) == key {
match = e.Name()
}
}
if match == "" {
break // дальше каталогов ещё нет
}
segs[i] = match
cur = filepath.Join(cur, match)
}
return strings.Join(segs, "/")
}
// removeStaging удаляет файл staging (и временные копии с тегами).
func (s *Service) removeStaging(f File) {
if f.StagingPath == "" {
return
}
p := filepath.Join(s.cfg.StagingDir, filepath.FromSlash(f.StagingPath))
matches, _ := filepath.Glob(p + "*")
for _, m := range matches {
_ = os.Remove(m)
}
_ = os.Remove(filepath.Dir(p)) // каталог пачки, если опустел
}
// TusURL — адрес tus для файла.
func TusURL(id uuid.UUID) string { return "/api/v1/uploads/files/" + id.String() }