Files
pipu-music/services/api/internal/uploads/model.go
T
mrleo1nidandCursor 066ba24834
ci / api / api (push) Successful in 40s
ci / db / db (push) Successful in 1m3s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 1m48s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 46s
ci / ts / ts (packages + web) (push) Failing after 4m57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 5m7s
feat: finish the upload, playback, and test gaps from the docs
Quality upgrades, batch covers, staging preview, and resume after reload are in the queue. Loudness and crossfade work on web and Android, and the documented checks now have suites.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 09:37:53 +03:00

178 lines
6.0 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
QualityUpgrade bool
TargetSourceID *uuid.UUID
TargetRelPath *string
StatusReason *string
ReviewedBy *uuid.UUID
ReviewedAt *time.Time
ImportedFileID *uuid.UUID
CoverPath *string
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"`
AllowQualityUpgrade bool `json:"allow_quality_upgrade"`
}
// 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