Files
pipu-music/services/api/internal/uploads/model.go
T
mrleo1nidandClaude Opus 5.5 ca106e8eb1
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
feat(api): upload queue with quota, tus intake, processing and import (M5.5)
- 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>
2026-10-20 17:23:05 +03:00

175 lines
5.9 KiB
Go

// Package uploads — очередь на добавление: резерв квоты, tus-приём файлов, обработка (ffprobe, хэши, Chromaprint,
// проверка дублей), рассмотрение админом и импорт в хранилище через storage-agent (docs/uploads.md).
package uploads
import (
"strconv"
"time"
"github.com/google/uuid"
"pipu/api/internal/library/meta"
)
// Status — состояние файла в очереди (docs/uploads.md#2-жизненный-цикл-загрузки).
type Status string
// Статусы загрузки.
const (
StatusUploading Status = "uploading"
StatusProcessing Status = "processing"
StatusPendingReview Status = "pending_review"
StatusApproved Status = "approved"
StatusImporting Status = "importing"
StatusImported Status = "imported"
StatusImportFailed Status = "import_failed"
StatusDuplicate Status = "duplicate"
StatusInvalid Status = "invalid"
StatusRejected Status = "rejected"
StatusWithdrawn Status = "withdrawn"
StatusExpired Status = "expired"
)
// Occupies — занимает ли файл в этом статусе место в очереди (docs/uploads.md#2, таблица).
func (s Status) Occupies() bool {
switch s {
case StatusUploading, StatusProcessing, StatusPendingReview, StatusApproved, StatusImporting, StatusImportFailed:
return true
}
return false
}
// KeepsStaging — файл в этом статусе ещё нужен в staging.
func (s Status) KeepsStaging() bool { return s.Occupies() }
// Коды ошибок и причин.
const (
CodeQueueFull = "uploads.queue_full"
CodeUserQuotaFull = "uploads.user_quota_full"
CodeTooLarge = "uploads.too_large"
CodeDuplicate = "uploads.duplicate"
CodeInvalidAudio = "uploads.invalid_audio"
CodeBadExtension = "uploads.unsupported_format"
CodeNotFound = "uploads.not_found"
CodeWrongStatus = "uploads.wrong_status"
CodeNoTarget = "uploads.no_target"
CodeOffset = "uploads.offset_mismatch"
CodeSizeMismatch = "uploads.size_mismatch"
CodeHashMismatch = "uploads.hash_mismatch"
CodeMissingField = "uploads.missing_field"
CodeImportFailed = "uploads.import_failed"
CodeTemplate = "uploads.no_template"
)
// Уровни дублей.
const (
DupSHA256 = "sha256"
DupMetadata = "metadata"
DupFingerprint = "fingerprint"
)
// Meta — итоговые поля трека загрузки; их видит и правит админ, по ним строится путь в хранилище.
type Meta struct {
Artist string `json:"artist"`
AlbumArtist string `json:"albumartist"`
Album string `json:"album"`
Title string `json:"title"`
Genre string `json:"genre"`
Year *int `json:"year,omitempty"`
Track *int `json:"track,omitempty"`
Disc *int `json:"disc,omitempty"`
}
// Fields — значения для шаблона пути.
func (m Meta) Fields() meta.Fields {
num := func(p *int) string {
if p == nil || *p <= 0 {
return ""
}
return strconv.Itoa(*p)
}
return meta.Fields{
Artist: m.Artist, AlbumArtist: m.AlbumArtist, Album: m.Album, Title: m.Title, Genre: m.Genre,
Year: num(m.Year), Track: num(m.Track), Disc: num(m.Disc),
}
}
// FromMerged — Meta из результата слияния тегов и пути.
func FromMerged(m meta.Meta) Meta {
ptr := func(v int) *int {
if v <= 0 {
return nil
}
return &v
}
out := Meta{Artist: m.Artist, AlbumArtist: m.AlbumArtist, Album: m.Album, Title: m.Title, Year: ptr(m.Year),
Track: ptr(m.Track), Disc: ptr(m.Disc)}
if len(m.Genres) > 0 {
out.Genre = m.Genres[0]
}
return out
}
// File — файл очереди.
type File struct {
ID uuid.UUID
BatchID uuid.UUID
AuthorID uuid.UUID
AuthorName string
BatchComment *string
Status Status
OriginalName string
ClientRelPath *string
Size int64
Received int64
SHA256 []byte
StagingPath string
Format *string
Codec *string
Bitrate *int
SampleRate *int
BitDepth *int
DurationMs *int
Tags map[string]string
Meta *Meta
MetadataEdited bool
MatchedArtistID *uuid.UUID
MatchedAlbumID *uuid.UUID
DuplicateOfTrackID *uuid.UUID
DuplicateLevel *string
PossibleDuplicate bool
TargetSourceID *uuid.UUID
TargetRelPath *string
StatusReason *string
ReviewedBy *uuid.UUID
ReviewedAt *time.Time
ImportedFileID *uuid.UUID
CreatedAt time.Time
UpdatedAt time.Time
}
// Settings — лимиты очереди и хранилище по умолчанию (app_settings, меняет админ).
type Settings struct {
QueueLimitBytes int64 `json:"queue_limit_bytes"`
PerUserLimitBytes *int64 `json:"per_user_limit_bytes,omitempty"` // nil — выключен
MaxFileBytes int64 `json:"max_file_bytes"`
DefaultSourceID *uuid.UUID `json:"default_source_id,omitempty"`
}
// DefaultSettings — значения по умолчанию (docs/uploads.md#3-квота-очереди).
var DefaultSettings = Settings{QueueLimitBytes: 10 << 30, MaxFileBytes: 2 << 30}
// AllowedExtensions — форматы, которые принимает очередь (содержимое всё равно проверяет ffprobe).
var AllowedExtensions = map[string]bool{
"flac": true, "mp3": true, "m4a": true, "ogg": true, "opus": true, "wav": true, "aiff": true, "aif": true, "wv": true,
}
// Пределы длительности (docs/uploads.md#5-загрузка).
const (
MinDurationMs = 5_000
MaxDurationMs = 3 * 60 * 60 * 1000
)
// ExpireAfter — недокачанная загрузка истекает (docs/uploads.md#11-очистка).
const ExpireAfter = 24 * time.Hour