- 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>
175 lines
5.9 KiB
Go
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
|