Files
pipu-music/services/api/internal/uploads/service.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

687 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
AllowQualityUpgrade *bool
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 in.AllowQualityUpgrade != nil {
st.AllowQualityUpgrade = *in.AllowQualityUpgrade
}
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() }
// Staged — файл очереди и абсолютный путь в staging.
type Staged struct {
File
Abs string
}
// OpenStaging возвращает файл, который ещё лежит в staging.
func (s *Service) OpenStaging(ctx context.Context, id uuid.UUID) (Staged, error) {
f, err := s.store.File(ctx, id)
if err != nil {
return Staged{}, notFound(err)
}
if f.StagingPath == "" || !f.Status.KeepsStaging() {
return Staged{}, apperr.NotFound(CodeNotFound, "file is not in staging")
}
return Staged{File: f, Abs: s.stagingAbs(f.StagingPath)}, nil
}