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
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>
178 lines
6.0 KiB
Go
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
|